From d15ce2e4363897496b7db5b532d08adb9132bc9e Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Thu, 5 Mar 2026 11:46:50 -0500 Subject: [PATCH 01/27] feat: add sync workflow --- .github/workflows/sync-from-public.yml | 136 +++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 .github/workflows/sync-from-public.yml diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml new file mode 100644 index 000000000..07d51875b --- /dev/null +++ b/.github/workflows/sync-from-public.yml @@ -0,0 +1,136 @@ +name: Sync from Public Repo + +on: + schedule: + - cron: '0 */6 * * *' # Every 6 hours + workflow_dispatch: # Manual trigger via Actions tab + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Fetch public main + run: | + git remote add public https://github.com/aws/agentcore-cli.git + git fetch public main + + - name: Sync all private branches with public/main + run: | + synced=() + conflicts=() + skipped=() + + for branch in $(git branch -r --list 'origin/*' | sed 's|^ *origin/||'); do + # Skip HEAD pointer and previous sync-conflict branches + if [ "$branch" = "HEAD" ] || [[ "$branch" == *"->"* ]] || [[ "$branch" == sync-conflict-* ]]; then + continue + fi + + echo "=== Processing branch: $branch ===" + + git checkout $branch + git reset --hard origin/$branch + + # Check if public/main is already merged into this branch + if git merge-base --is-ancestor public/main HEAD; then + echo "✅ $branch is already up to date with public/main" + synced+=("$branch (already up to date)") + continue + fi + + # Try to merge public/main + if git merge public/main -m "chore: sync $branch with public/main"; then + git push origin $branch + synced+=("$branch") + echo "✅ $branch synced successfully" + else + echo "⚠️ Conflict detected in $branch" + + # Capture conflicted files before aborting + conflicted_files=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "Unable to determine conflicted files") + git merge --abort + + # Check if a sync PR already exists for this branch + existing_pr=$(gh pr list --base "$branch" --search "Sync public/main" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + + if [ -n "$existing_pr" ]; then + echo "ℹ️ PR #$existing_pr already exists for $branch, skipping" + skipped+=("$branch (existing PR #$existing_pr)") + continue + fi + + conflict_branch="sync-conflict-$branch-$(date +%Y%m%d-%H%M%S)" + git checkout -b "$conflict_branch" + + git merge public/main --no-commit --no-ff || true + git add -A + git commit -m "chore: sync $branch with public/main (conflicts present) + + This automated sync detected merge conflicts that require manual resolution. + + Source: public/main (https://github.com/aws/agentcore-cli) + Target: $branch + + Please resolve conflicts and merge this PR." || true + + git push origin "$conflict_branch" + + gh pr create \ + --title "🔀 [Sync Conflict] Merge public/main → $branch" \ + --body "## Automated Sync Conflict + + This PR was automatically created because merging \`public/main\` into \`$branch\` encountered conflicts. + + **Source:** \`main\` from [aws/agentcore-cli](https://github.com/aws/agentcore-cli) + **Target:** \`$branch\` + + ### Action Required + 1. \`git fetch origin && git checkout $conflict_branch\` + 2. Resolve merge conflicts + 3. \`git add . && git commit\` + 4. \`git push origin $conflict_branch\` + 5. Merge this PR + + ### Files with Conflicts + \`\`\` + $conflicted_files + \`\`\`" \ + --base "$branch" \ + --head "$conflict_branch" || echo "⚠️ Failed to create PR for $branch" + + conflicts+=("$branch") + git checkout "$branch" + fi + done + + # Summary + echo "" + echo "=== Sync Summary ===" + echo "✅ Synced: ${#synced[@]} branches" + echo "⚠️ Conflicts: ${#conflicts[@]} branches" + echo "⏭️ Skipped: ${#skipped[@]} branches" + + if [ ${#conflicts[@]} -gt 0 ]; then + echo "Branches with conflicts (PRs created):" + printf ' - %s\n' "${conflicts[@]}" + fi + if [ ${#skipped[@]} -gt 0 ]; then + echo "Branches skipped (existing PRs):" + printf ' - %s\n' "${skipped[@]}" + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 7d35986ed02c3efff983a0192bdff8a9a298d9d9 Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Thu, 5 Mar 2026 13:41:24 -0500 Subject: [PATCH 02/27] fix: formatting --- .github/workflows/sync-from-public.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml index 07d51875b..63fbdeb5e 100644 --- a/.github/workflows/sync-from-public.yml +++ b/.github/workflows/sync-from-public.yml @@ -2,8 +2,8 @@ name: Sync from Public Repo on: schedule: - - cron: '0 */6 * * *' # Every 6 hours - workflow_dispatch: # Manual trigger via Actions tab + - cron: '0 */6 * * *' # Every 6 hours + workflow_dispatch: # Manual trigger via Actions tab permissions: contents: write From 2acb841f7959e03af06863a0ad82b899cfcf977b Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Mon, 9 Mar 2026 11:30:00 -0400 Subject: [PATCH 03/27] fix: only sync to main branch --- .github/workflows/sync-from-public.yml | 109 +++++++++---------------- 1 file changed, 37 insertions(+), 72 deletions(-) diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml index 63fbdeb5e..587219bf8 100644 --- a/.github/workflows/sync-from-public.yml +++ b/.github/workflows/sync-from-public.yml @@ -28,75 +28,60 @@ jobs: git remote add public https://github.com/aws/agentcore-cli.git git fetch public main - - name: Sync all private branches with public/main + - name: Sync main with public/main run: | - synced=() - conflicts=() - skipped=() - - for branch in $(git branch -r --list 'origin/*' | sed 's|^ *origin/||'); do - # Skip HEAD pointer and previous sync-conflict branches - if [ "$branch" = "HEAD" ] || [[ "$branch" == *"->"* ]] || [[ "$branch" == sync-conflict-* ]]; then - continue - fi - - echo "=== Processing branch: $branch ===" - - git checkout $branch - git reset --hard origin/$branch + git checkout main + git reset --hard origin/main - # Check if public/main is already merged into this branch - if git merge-base --is-ancestor public/main HEAD; then - echo "✅ $branch is already up to date with public/main" - synced+=("$branch (already up to date)") - continue - fi + # Check if public/main is already merged + if git merge-base --is-ancestor public/main HEAD; then + echo "✅ main is already up to date with public/main" + exit 0 + fi - # Try to merge public/main - if git merge public/main -m "chore: sync $branch with public/main"; then - git push origin $branch - synced+=("$branch") - echo "✅ $branch synced successfully" - else - echo "⚠️ Conflict detected in $branch" + # Try to merge public/main + if git merge public/main -m "chore: sync main with public/main"; then + git push origin main + echo "✅ main synced successfully" + else + echo "⚠️ Conflict detected in main" - # Capture conflicted files before aborting - conflicted_files=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "Unable to determine conflicted files") - git merge --abort + # Capture conflicted files before aborting + conflicted_files=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "Unable to determine conflicted files") + git merge --abort - # Check if a sync PR already exists for this branch - existing_pr=$(gh pr list --base "$branch" --search "Sync public/main" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + # Check if a sync PR already exists + existing_pr=$(gh pr list --base "main" --search "Merge public/main" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") - if [ -n "$existing_pr" ]; then - echo "ℹ️ PR #$existing_pr already exists for $branch, skipping" - skipped+=("$branch (existing PR #$existing_pr)") - continue - fi + if [ -n "$existing_pr" ]; then + echo "ℹ️ PR #$existing_pr already exists, skipping" + exit 0 + fi - conflict_branch="sync-conflict-$branch-$(date +%Y%m%d-%H%M%S)" - git checkout -b "$conflict_branch" + conflict_branch="sync-conflict-main-$(date +%Y%m%d-%H%M%S)" + git checkout -b "$conflict_branch" - git merge public/main --no-commit --no-ff || true - git add -A - git commit -m "chore: sync $branch with public/main (conflicts present) + git merge public/main --no-commit --no-ff || true + git add -A + git commit -m "chore: sync main with public/main (conflicts present) This automated sync detected merge conflicts that require manual resolution. Source: public/main (https://github.com/aws/agentcore-cli) - Target: $branch + Target: main Please resolve conflicts and merge this PR." || true - git push origin "$conflict_branch" + git push origin "$conflict_branch" - gh pr create \ - --title "🔀 [Sync Conflict] Merge public/main → $branch" \ - --body "## Automated Sync Conflict + gh pr create \ + --title "🔀 [Sync Conflict] Merge public/main → main" \ + --body "## Automated Sync Conflict - This PR was automatically created because merging \`public/main\` into \`$branch\` encountered conflicts. + This PR was automatically created because merging \`public/main\` into \`main\` encountered conflicts. **Source:** \`main\` from [aws/agentcore-cli](https://github.com/aws/agentcore-cli) - **Target:** \`$branch\` + **Target:** \`main\` ### Action Required 1. \`git fetch origin && git checkout $conflict_branch\` @@ -109,28 +94,8 @@ jobs: \`\`\` $conflicted_files \`\`\`" \ - --base "$branch" \ - --head "$conflict_branch" || echo "⚠️ Failed to create PR for $branch" - - conflicts+=("$branch") - git checkout "$branch" - fi - done - - # Summary - echo "" - echo "=== Sync Summary ===" - echo "✅ Synced: ${#synced[@]} branches" - echo "⚠️ Conflicts: ${#conflicts[@]} branches" - echo "⏭️ Skipped: ${#skipped[@]} branches" - - if [ ${#conflicts[@]} -gt 0 ]; then - echo "Branches with conflicts (PRs created):" - printf ' - %s\n' "${conflicts[@]}" - fi - if [ ${#skipped[@]} -gt 0 ]; then - echo "Branches skipped (existing PRs):" - printf ' - %s\n' "${skipped[@]}" + --base "main" \ + --head "$conflict_branch" || echo "⚠️ Failed to create PR" fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 05258f31ea7b28644a527fb7a3afb7727c85e3a3 Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Mon, 9 Mar 2026 12:01:37 -0400 Subject: [PATCH 04/27] fix: codeql permissions --- .github/workflows/codeql.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1e9b0a4bd..0b3f65d25 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,6 +19,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 permissions: + actions: read security-events: write contents: read From 37a0ff8812fb019eb18a99888544b0fd10acdeab Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:32:51 -0400 Subject: [PATCH 05/27] fix(ci): exclude .github/workflows/ from public repo sync (#54) GITHUB_TOKEN lacks the 'workflows' permission, so pushing workflow file changes from the public repo causes the sync to fail. Use --no-commit --no-ff and restore .github/workflows/ from HEAD before committing, in both the clean merge and conflict paths. --- .github/workflows/sync-from-public.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml index 587219bf8..94e279079 100644 --- a/.github/workflows/sync-from-public.yml +++ b/.github/workflows/sync-from-public.yml @@ -39,8 +39,10 @@ jobs: exit 0 fi - # Try to merge public/main - if git merge public/main -m "chore: sync main with public/main"; then + # Merge but exclude .github/workflows/ (GITHUB_TOKEN lacks workflow permission) + if git merge public/main --no-commit --no-ff; then + git checkout HEAD -- .github/workflows/ 2>/dev/null || true + git commit -m "chore: sync main with public/main" git push origin main echo "✅ main synced successfully" else @@ -62,6 +64,7 @@ jobs: git checkout -b "$conflict_branch" git merge public/main --no-commit --no-ff || true + git checkout HEAD -- .github/workflows/ 2>/dev/null || true git add -A git commit -m "chore: sync main with public/main (conflicts present) From 06a468ea0929d252639d5b0aa018d88c21fa34ed Mon Sep 17 00:00:00 2001 From: Trirmadura J Ariyawansa Date: Mon, 27 Apr 2026 18:56:58 -0400 Subject: [PATCH 06/27] chore: sync with public/main (2026-04-27) (#143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add GitHub Action for automated PR review via AgentCore Harness (#934) * feat: add GitHub Action for automated PR review via AgentCore Harness Adds a workflow that reviews PRs using Bedrock AgentCore Harness. The harness runs an AI agent in an isolated microVM with gh, git, and pre-cloned repos that fetches PR diffs and posts review comments. Workflow: - Triggers on PR open/reopen for agentcore-cli-devs team members - Supports manual workflow_dispatch for any PR URL - Adds/removes ai-reviewing label during review - Authenticates via GitHub OIDC to assume AWS role Files: - .github/workflows/pr-ai-review.yml — main workflow - .github/scripts/python/harness_review.py — harness invocation script - .github/scripts/python/harness_config.py — config from env vars - .github/scripts/models/ — local boto3 service model (InvokeHarness not yet in standard boto3) Required secrets: - HARNESS_AWS_ROLE_ARN — IAM role ARN for OIDC - HARNESS_ACCOUNT_ID — AWS account ID - HARNESS_ID — Harness ID * refactor: replace local service model with raw HTTP + SigV4 signing Eliminates the 220KB bundled service model by using direct HTTP requests with SigV4 authentication to invoke the harness endpoint. No extra dependencies needed — urllib3, SigV4Auth, and EventStreamBuffer are all part of botocore/boto3. Rejected: invoke_agent_runtime API | server rejects harness ARNs with ResourceNotFoundException Confidence: high Scope-risk: moderate * refactor: inline harness config into review script Remove separate harness_config.py — env vars are read directly in harness_review.py. One less file to maintain, config is still driven entirely by environment variables set in the GitHub workflow. * refactor: extract invoke_harness helper for cleaner main flow * refactor: simplify config and improve script readability - Replace HARNESS_ACCOUNT_ID + HARNESS_ID with single HARNESS_ARN env var - Extract prompts into separate .md files in .github/scripts/prompts/ - Extract stream parsing into print_stream() function - Add close_group() helper to deduplicate ::group:: bookkeeping * refactor: separate event parsing from display logic Extract parse_events() generator to handle binary stream decoding, keeping print_stream() focused on formatting and log groups. * docs: add explanatory comments to harness review functions * refactor: derive region from HARNESS_ARN instead of separate env var Eliminates HARNESS_REGION env var — the region is extracted from the ARN directly, so there's no risk of a mismatch causing confusing SigV4 auth errors. * chore: rename label to agentcore-harness-reviewing * refactor: move auth check to job level so entire review is skipped early Split into authorize + ai-review jobs. The ai-review job only runs if the PR author is authorized (team member or write access) or if triggered via workflow_dispatch. Removes repeated if conditions from every step. * chore: exclude AI prompt templates from prettier Prompt markdown files use intentional formatting that prettier would reflow, breaking the prompt structure. * fix: buffer streaming text to avoid per-token log lines in GitHub Actions (#946) Each text delta from the harness was printed individually with flush, creating a separate log line per token. Now text is buffered and flushed as complete lines at block boundaries. * fix: allow code-based evaluators in online eval configs (#947) * fix: allow code-based evaluators in online eval configs Remove restrictions that blocked code-based evaluators from being used in online evaluation configs. The service now supports code-based evaluators for online evaluation. Changes: - Remove code-based evaluator block in OnlineEvalConfigPrimitive - Remove code-based evaluator validation in schema superRefine - Remove code-based evaluator filter in TUI evaluator picker * style: fix prettier formatting * fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs (#949) * fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs When commands are invoked without flags in non-interactive environments (CI, piped stdin, agent automation), the CLI falls through to Ink TUI rendering which hangs indefinitely. Add a requireTTY() guard at every TUI entry point that checks process.stdout.isTTY and exits with a helpful error message directing users to --help for non-interactive flags. Closes #685 * fix: check both stdin and stdout isTTY in requireTTY guard The hang from #685 is caused by stdin not being a TTY (Ink reads keyboard input from stdin), not stdout. Check both stdin and stdout so the guard fires for piped stdin, redirected stdout, and CI environments where both are non-TTY. * fix: agentcore dev not working in windows (#951) * fix: use pull_request_target for fork PR support (#958) * fix: make label step non-blocking for fork PRs Fork PRs get read-only GITHUB_TOKEN regardless of workflow permissions, causing the addLabels API call to fail with 403. This crashed the entire job before the review could run. continue-on-error lets the review proceed even when labeling fails. * fix: use pull_request_target for full write access on fork PRs pull_request gives a read-only GITHUB_TOKEN for fork PRs, preventing labels and secrets from working. pull_request_target runs in the base repo context with full permissions. This is safe because we never check out or execute fork code — the harness fetches the PR diff via the GitHub API. * fix: lower eventExpiryDuration minimum from 7 to 3 days (closes #744) (#956) The AWS CreateMemory API allows a minimum of 3 days, but the CLI schema was rejecting values below 7. Update the Zod schema, LLM compacted types, import clamping logic, and all related tests. * fix: display session ID after CLI invoke completes (#957) * fix: display session ID after CLI invoke completes (closes #664) The streaming and non-streaming invoke responses include a session ID from the runtime, but the CLI paths discarded it. Now prints the session ID and a resume command hint after invoke output. * fix: include sessionId in AGUI protocol invoke result * test: add browser tests for agent inspector (#938) * feat: add telemetry schemas and client (#941) * chore: bump version to 0.11.0 (#967) Co-authored-by: github-actions[bot] * fix(invoke): auto-generate session ID for bearer-token invocations (#953) Closes #840 When invoking an agent with a bearer token (OAuth/CUSTOM_JWT) and no session ID, `AgentCoreMemoryConfig` raised a Pydantic validation error because `session_id=None` is rejected. Unlike SigV4 callers, bearer-token callers do not get a server-side auto-generated runtime session ID. Two-layer fix: 1. CLI synthesizes a UUID in `invoke` action when `--bearer-token` is set and `--session-id` is missing, using the existing `generateSessionId` helper. Covers both explicit `--bearer-token` and the CUSTOM_JWT auto-fetch path. 2. Strands memory session templates (http, agui, a2a) synthesize a UUID when `session_id` is falsy before constructing AgentCoreMemoryConfig. Protects direct runtime callers (curl, custom apps) who forget the `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` header. Snapshot tests updated. * fix: show 'Computing diff changes...' step during deploy diff phase (#952) The deploy TUI appeared frozen for 5-15 seconds between preflight completion and 'Publish assets' while cdkToolkitWrapper.diff() ran silently with no step marked as running. Add a dedicated pre-deploy diff step that transitions running -> success around the diff call so StepProgress always has something to highlight. Closes #781 * test: split browser tests into its own job, fix logs path (#975) * feat(invoke): add --prompt-file and stdin support for long prompts (#974) * feat(invoke): add --prompt-file and stdin support for long prompts Long prompts hit shell argument limits (E2BIG, typically 128KB-2MB) when passed as positional args. This adds two new sources: - --prompt-file : read prompt from a file - piped stdin: when no prompt is given and stdin is not a TTY, read the prompt from stdin Precedence is hybrid and backward-compatible: --prompt > positional > --prompt-file > stdin --prompt-file combined with piped stdin content returns an explicit collision error rather than silently picking one. Closes #686 * docs(invoke): document --prompt-file and stdin support * fix(import): remove experimental warning from import command (#977) The import feature has stabilized and no longer needs the experimental label. * fix: duplicate header flash and help menu truncation (closes #895, closes #637) (#955) - Return null during brief transitional phases to prevent Ink from rendering a header that gets immediately replaced by a different frame - Consolidate CreateScreen phases into a single Screen mount - Make help menu description width responsive to terminal size - Remove hardcoded 50-char description truncation limit * test: configure git in browser tests workflow (#976) * feat: add project-name option to create (#969) * Add project-name option to create * fix: address review feedback — restore name description and move backfill logic * ci: bump the github-actions group across 1 directory with 4 updates (#964) Bumps the github-actions group with 4 updates in the / directory: [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials), [actions/github-script](https://github.com/actions/github-script), [softprops/action-gh-release](https://github.com/softprops/action-gh-release) and [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action). Updates `aws-actions/configure-aws-credentials` from 5 to 6 - [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/v5...v6) Updates `actions/github-script` from 8 to 9 - [Commits](https://github.com/actions/github-script/compare/v8...v9) Updates `softprops/action-gh-release` from 2 to 3 - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) Updates `slackapi/slack-github-action` from 3.0.1 to 3.0.2 - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Changelog](https://github.com/slackapi/slack-github-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/slackapi/slack-github-action/compare/v3.0.1...v3.0.2) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: aws-actions/configure-aws-credentials dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: slackapi/slack-github-action dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump aws-cdk-lib (#962) Bumps the aws-cdk group with 1 update in the / directory: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib). Updates `aws-cdk-lib` from 2.248.0 to 2.250.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.250.0/packages/aws-cdk-lib) --- updated-dependencies: - dependency-name: aws-cdk-lib dependency-version: 2.250.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump postcss from 8.5.8 to 8.5.10 (#961) Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.10. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.10 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump secretlint from 11.4.1 to 12.2.0 (#916) Bumps [secretlint](https://github.com/secretlint/secretlint) from 11.4.1 to 12.2.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v11.4.1...v12.2.0) --- updated-dependencies: - dependency-name: secretlint dependency-version: 12.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @vitest/coverage-v8 from 4.1.2 to 4.1.5 (#915) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.2 to 4.1.5. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @secretlint/secretlint-rule-preset-recommend (#914) Bumps [@secretlint/secretlint-rule-preset-recommend](https://github.com/secretlint/secretlint) from 11.4.1 to 12.2.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v11.4.1...v12.2.0) --- updated-dependencies: - dependency-name: "@secretlint/secretlint-rule-preset-recommend" dependency-version: 12.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the aws-sdk group across 1 directory with 14 updates (#912) Bumps the aws-sdk group with 14 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@aws-sdk/client-application-signals](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-application-signals) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agentcore](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agentcore-control](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore-control) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cloudformation](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudformation) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cloudwatch-logs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudwatch-logs) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-resource-groups-tagging-api](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-resource-groups-tagging-api) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-xray](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-xray) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/credential-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/credential-providers) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cognito-identity-provider](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cognito-identity-provider) | `3.1036.0` | `3.1037.0` | Updates `@aws-sdk/client-application-signals` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-application-signals/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-application-signals) Updates `@aws-sdk/client-bedrock` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock) Updates `@aws-sdk/client-bedrock-agent` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agent) Updates `@aws-sdk/client-bedrock-agentcore` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agentcore) Updates `@aws-sdk/client-bedrock-agentcore-control` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore-control/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agentcore-control) Updates `@aws-sdk/client-bedrock-runtime` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-runtime) Updates `@aws-sdk/client-cloudformation` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudformation/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cloudformation) Updates `@aws-sdk/client-cloudwatch-logs` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudwatch-logs/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cloudwatch-logs) Updates `@aws-sdk/client-resource-groups-tagging-api` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-resource-groups-tagging-api/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-resource-groups-tagging-api) Updates `@aws-sdk/client-s3` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-s3) Updates `@aws-sdk/client-sts` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-sts) Updates `@aws-sdk/client-xray` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-xray/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-xray) Updates `@aws-sdk/credential-providers` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/credential-providers/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/packages/credential-providers) Updates `@aws-sdk/client-cognito-identity-provider` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cognito-identity-provider/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cognito-identity-provider) --- updated-dependencies: - dependency-name: "@aws-sdk/client-application-signals" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agent" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore-control" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-runtime" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudformation" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudwatch-logs" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cognito-identity-provider" dependency-version: 3.1034.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-resource-groups-tagging-api" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-xray" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/credential-providers" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump hono from 4.12.12 to 4.12.14 (#868) Bumps [hono](https://github.com/honojs/hono) from 4.12.12 to 4.12.14. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.12...v4.12.14) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.14 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump esbuild from 0.27.4 to 0.28.0 (#862) Bumps [esbuild](https://github.com/evanw/esbuild) from 0.27.4 to 0.28.0. - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.27.4...v0.28.0) --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.28.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * test: speed up CI and fix mock cleanup gaps (#989) * test: speed up CI and fix mock cleanup gaps - Node 20 only on PRs (full matrix on main) - 3-way vitest sharding for unit tests with blob report merging - Pre-bundle heavy deps (AWS SDK, Smithy, zod, commander) via deps.optimizer - Exclude tui-harness from unit test project (not production code) - Add afterEach(vi.restoreAllMocks) to 3 files with mock cleanup gaps - Move inline consoleSpy.mockRestore() to afterEach in logs-eval tests - Skip PTY tests when node-pty spawn is unavailable * style: fix prettier formatting in build-and-test.yml * fix: enable include-hidden-files for blob artifact upload upload-artifact@v7 defaults include-hidden-files to false, which skips the .vitest-reports directory. Also fail loudly if no files found. * feat: runtime endpoint support in AgentCore CLI (#979) * feat: add runtime endpoint support to AgentCore CLI - Schema: endpoints field on AgentEnvSpec, runtimeVersion in deployed state - Primitive: RuntimeEndpointPrimitive with add/remove/preview - TUI: Add and Remove flows with multi-field form - Status: endpoints nested under agents with deployment badges - Deploy: parseRuntimeEndpointOutputs + buildDeployedState pipeline * fix: correct output key prefix for runtime endpoint parsing The CFN output keys include the AgentEnvironment construct prefix (Agent{PascalName}) which was missing from the parser pattern. * fix: remove .omc state files and unused useCallback import - Remove .omc/ from git tracking, add to .gitignore - Remove unused useCallback import in AddRuntimeEndpointScreen.tsx * fix: shorten runtime endpoint description to prevent TUI overflow The description "Named endpoint (version alias) for a runtime" was too long and wrapped to the next line in the Add Resource menu. Shortened to "Named endpoint for a runtime". * fix: validate runtime endpoint version is a positive integer - Add explicit Number.isInteger check before schema validation - Change Commander parser from parseInt to Number so floats like 3.5 are caught instead of silently truncated * fix: use agent/endpoint composite key to prevent React key collision Endpoint names can collide across runtimes (e.g., both have "prod"). Changed React key from epName to agent.name/epName to prevent duplicate key warnings that pollute the TUI viewport. * fix: render runtime endpoints in status --type runtime-endpoint When filtering by --type runtime-endpoint, agents array is empty so the agents section (which nests endpoints) never renders. Added a standalone Runtime Endpoints section that shows when endpoints exist but agents don't (i.e., when type-filtering). * fix: add runtime-endpoint to status --help --type documentation The --type option help text was missing runtime-endpoint from the list of valid resource types. * fix: return richer JSON response from add runtime-endpoint add now returns { success, endpointName, agent, version } instead of sparse { success: true }, matching the richer response shape from remove runtime-endpoint. * fix: validate endpoint version against deployed runtime version - TUI: show "Current deployed version: N" and valid range (1-N) - TUI: reject version exceeding latest deployed version - CLI: check deployed-state.json for max version, reject if exceeded - If runtime not deployed, only positive integer check applies * chore: remove planning and bug bash docs from PR * fix: use composite key and parentName for endpoint identification - Add parentName field to ResourceStatusEntry for structured parent linking - Use runtimeName/endpointName composite key in remove/preview/getRemovable - Status command filters endpoints by parentName instead of parsing detail string - React keys use structured parentName/name instead of display strings * test: add comprehensive unit tests for RuntimeEndpointPrimitive 23 tests covering add(), remove(), previewRemove(), getRemovable(): - Runtime lookup, duplicate detection, version validation - Composite key removal targeting correct runtime - Empty endpoints dict cleanup - Version validation against deployed state - Richer JSON response shape * fix: remove dead findGatewayTargetReferences stub * fix: use BasePrimitive configIO instead of ad-hoc ConfigIO in add() * fix: use Number() instead of parseInt in TUI version validation * chore: fix prettier formatting * fix: use T[] instead of Array to satisfy eslint array-type rule * fix(ci): revert schema file to avoid schema-check guard The schemas/ directory is auto-regenerated during the release workflow. Direct modifications are blocked by CI. * Revert "fix(ci): revert schema file to avoid schema-check guard" This reverts commit 3615e37a0aaa71cd4d2c5c7b19e3ddb41eb2e07c. --------- Signed-off-by: dependabot[bot] Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Co-authored-by: Avi Alpert <131792194+avi-alpert@users.noreply.github.com> Co-authored-by: Gitika <53349492+notgitika@users.noreply.github.com> Co-authored-by: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Co-authored-by: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/scripts/prompts/review.md | 13 + .github/scripts/prompts/system.md | 21 + .github/scripts/python/harness_review.py | 217 + .gitignore | 6 + .prettierignore | 1 + CHANGELOG.md | 18 + browser-tests/constants.ts | 3 + browser-tests/fixtures.ts | 56 + browser-tests/global-setup.ts | 144 + browser-tests/global-teardown.ts | 39 + browser-tests/playwright.config.ts | 33 + browser-tests/tests/chat-invocation.test.ts | 10 + browser-tests/tests/inspector-loads.test.ts | 13 + browser-tests/tests/resources.test.ts | 19 + browser-tests/tests/start-agent.test.ts | 16 + browser-tests/tests/traces.test.ts | 22 + docs/TESTING.md | 68 +- docs/commands.md | 43 +- eslint.config.mjs | 6 +- package-lock.json | 4129 ++++++++++------- package.json | 15 +- schemas/agentcore.schema.v1.json | 2 +- .../assets.snapshot.test.ts.snap | 21 +- .../strands/capabilities/memory/session.py | 7 +- .../strands/capabilities/memory/session.py | 7 +- .../strands/capabilities/memory/session.py | 7 +- src/cli/cli.ts | 2 + src/cli/cloudformation/outputs.ts | 42 + src/cli/commands/add/command.tsx | 3 +- .../commands/create/__tests__/create.test.ts | 65 + .../create/__tests__/validate.test.ts | 37 + src/cli/commands/create/action.ts | 26 +- src/cli/commands/create/command.tsx | 20 +- src/cli/commands/create/types.ts | 1 + src/cli/commands/create/validate.ts | 20 +- src/cli/commands/deploy/actions.ts | 13 + src/cli/commands/deploy/command.tsx | 4 +- src/cli/commands/dev/command.tsx | 3 +- .../import/__tests__/import-memory.test.ts | 10 +- .../import/__tests__/merge-logic.test.ts | 4 +- src/cli/commands/import/actions.ts | 2 +- src/cli/commands/import/command.ts | 2 +- src/cli/commands/import/import-memory.ts | 2 +- .../invoke/__tests__/resolve-prompt.test.ts | 88 + src/cli/commands/invoke/action.ts | 12 + src/cli/commands/invoke/command.tsx | 48 +- src/cli/commands/invoke/resolve-prompt.ts | 70 + src/cli/commands/invoke/types.ts | 3 + .../__tests__/subcommand-priority.test.ts | 4 +- src/cli/commands/remove/command.tsx | 4 +- src/cli/commands/remove/types.ts | 1 + src/cli/commands/status/action.ts | 36 +- src/cli/commands/status/command.tsx | 48 +- src/cli/commands/telemetry/actions.ts | 2 +- src/cli/logging/remove-logger.ts | 1 + .../dev/__tests__/codezip-dev-server.test.ts | 4 +- .../dev/__tests__/utils-open-browser.test.ts | 57 + src/cli/operations/dev/utils.ts | 8 + .../__tests__/resolve-ui-dist-dir.test.ts | 62 + .../operations/dev/web-ui/handlers/start.ts | 8 +- src/cli/operations/dev/web-ui/run-web-ui.ts | 5 +- src/cli/operations/dev/web-ui/web-server.ts | 5 +- .../eval/__tests__/logs-eval.test.ts | 10 +- .../get-agent-scoped-credentials.test.ts | 4 +- src/cli/primitives/AgentPrimitive.tsx | 2 + src/cli/primitives/BasePrimitive.ts | 2 + src/cli/primitives/CredentialPrimitive.tsx | 2 + src/cli/primitives/EvaluatorPrimitive.ts | 2 + src/cli/primitives/GatewayPrimitive.ts | 2 + src/cli/primitives/GatewayTargetPrimitive.ts | 2 + src/cli/primitives/MemoryPrimitive.tsx | 2 + .../primitives/OnlineEvalConfigPrimitive.ts | 14 +- src/cli/primitives/PolicyEnginePrimitive.ts | 3 + src/cli/primitives/PolicyPrimitive.ts | 3 + .../primitives/RuntimeEndpointPrimitive.ts | 354 ++ .../RuntimeEndpointPrimitive.test.ts | 354 ++ src/cli/primitives/index.ts | 3 + src/cli/primitives/registry.ts | 3 + src/cli/telemetry/__tests__/client.test.ts | 146 + .../__tests__/composite-sink.test.ts | 60 + .../__tests__/error-classification.test.ts | 63 + src/cli/telemetry/__tests__/resolve.test.ts | 2 +- .../__tests__/resource-resolver.test.ts | 50 + src/cli/telemetry/client.ts | 97 + src/cli/telemetry/config.ts | 61 + src/cli/telemetry/error-classification.ts | 62 + src/cli/telemetry/index.ts | 8 +- src/cli/telemetry/resolve.ts | 29 - .../schemas/__tests__/command-run.test.ts | 172 + src/cli/telemetry/schemas/command-run.ts | 221 + .../telemetry/schemas/common-attributes.ts | 30 + src/cli/telemetry/schemas/common-shapes.ts | 78 + src/cli/telemetry/schemas/index.ts | 13 + src/cli/telemetry/sinks/in-memory-sink.ts | 19 + src/cli/telemetry/sinks/metric-sink.ts | 34 + src/cli/telemetry/sinks/otel-metric-sink.ts | 51 + src/cli/tui/components/ResourceGraph.tsx | 40 +- src/cli/tui/copy.ts | 2 +- src/cli/tui/guards/index.ts | 1 + src/cli/tui/guards/tty.ts | 13 + src/cli/tui/hooks/useRemove.ts | 23 + src/cli/tui/screens/add/AddFlow.tsx | 19 + src/cli/tui/screens/add/AddScreen.tsx | 1 + src/cli/tui/screens/create/CreateScreen.tsx | 145 +- src/cli/tui/screens/deploy/DeployScreen.tsx | 207 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 15 +- .../tui/screens/home/CommandListScreen.tsx | 9 +- src/cli/tui/screens/home/HelpScreen.tsx | 23 +- .../tui/screens/import/ImportSelectScreen.tsx | 15 +- .../screens/online-eval/AddOnlineEvalFlow.tsx | 17 +- src/cli/tui/screens/remove/RemoveFlow.tsx | 110 +- .../remove/RemoveRuntimeEndpointScreen.tsx | 29 + src/cli/tui/screens/remove/RemoveScreen.tsx | 11 + .../remove/__tests__/RemoveScreen.test.tsx | 2 + src/cli/tui/screens/remove/index.ts | 1 + .../AddRuntimeEndpointFlow.tsx | 131 + .../AddRuntimeEndpointScreen.tsx | 268 ++ src/cli/tui/screens/runtime-endpoint/index.ts | 2 + src/cli/tui/screens/runtime-endpoint/types.ts | 8 + .../useAddRuntimeEndpointWizard.ts | 61 + src/schema/llm-compacted/agentcore.ts | 2 +- .../schemas/__tests__/agent-env.test.ts | 128 + .../__tests__/agentcore-project.test.ts | 8 +- .../schemas/__tests__/deployed-state.test.ts | 32 + src/schema/schemas/agent-env.ts | 27 + src/schema/schemas/agentcore-project.ts | 11 +- src/schema/schemas/deployed-state.ts | 14 + src/test-utils/cli-runner.ts | 1 + .../__tests__/proof-of-concept.test.ts | 12 +- tsconfig.json | 1 + vitest.config.ts | 9 +- 131 files changed, 6995 insertions(+), 2019 deletions(-) create mode 100644 .github/scripts/prompts/review.md create mode 100644 .github/scripts/prompts/system.md create mode 100644 .github/scripts/python/harness_review.py create mode 100644 browser-tests/constants.ts create mode 100644 browser-tests/fixtures.ts create mode 100644 browser-tests/global-setup.ts create mode 100644 browser-tests/global-teardown.ts create mode 100644 browser-tests/playwright.config.ts create mode 100644 browser-tests/tests/chat-invocation.test.ts create mode 100644 browser-tests/tests/inspector-loads.test.ts create mode 100644 browser-tests/tests/resources.test.ts create mode 100644 browser-tests/tests/start-agent.test.ts create mode 100644 browser-tests/tests/traces.test.ts create mode 100644 src/cli/commands/invoke/__tests__/resolve-prompt.test.ts create mode 100644 src/cli/commands/invoke/resolve-prompt.ts create mode 100644 src/cli/operations/dev/__tests__/utils-open-browser.test.ts create mode 100644 src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts create mode 100644 src/cli/primitives/RuntimeEndpointPrimitive.ts create mode 100644 src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts create mode 100644 src/cli/telemetry/__tests__/client.test.ts create mode 100644 src/cli/telemetry/__tests__/composite-sink.test.ts create mode 100644 src/cli/telemetry/__tests__/error-classification.test.ts create mode 100644 src/cli/telemetry/__tests__/resource-resolver.test.ts create mode 100644 src/cli/telemetry/client.ts create mode 100644 src/cli/telemetry/config.ts create mode 100644 src/cli/telemetry/error-classification.ts delete mode 100644 src/cli/telemetry/resolve.ts create mode 100644 src/cli/telemetry/schemas/__tests__/command-run.test.ts create mode 100644 src/cli/telemetry/schemas/command-run.ts create mode 100644 src/cli/telemetry/schemas/common-attributes.ts create mode 100644 src/cli/telemetry/schemas/common-shapes.ts create mode 100644 src/cli/telemetry/schemas/index.ts create mode 100644 src/cli/telemetry/sinks/in-memory-sink.ts create mode 100644 src/cli/telemetry/sinks/metric-sink.ts create mode 100644 src/cli/telemetry/sinks/otel-metric-sink.ts create mode 100644 src/cli/tui/guards/tty.ts create mode 100644 src/cli/tui/screens/remove/RemoveRuntimeEndpointScreen.tsx create mode 100644 src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx create mode 100644 src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointScreen.tsx create mode 100644 src/cli/tui/screens/runtime-endpoint/index.ts create mode 100644 src/cli/tui/screens/runtime-endpoint/types.ts create mode 100644 src/cli/tui/screens/runtime-endpoint/useAddRuntimeEndpointWizard.ts diff --git a/.github/scripts/prompts/review.md b/.github/scripts/prompts/review.md new file mode 100644 index 000000000..0a4f85fc7 --- /dev/null +++ b/.github/scripts/prompts/review.md @@ -0,0 +1,13 @@ +Review this GitHub PR: {pr_url} + +You have tools to fetch the PR diff, read files, search the web, and post comments on the PR. + +You have these repos cloned locally for context: +- /opt/workspace/agentcore-cli — aws/agentcore-cli +- /opt/workspace/agentcore-l3-cdk-constructs — aws/agentcore-l3-cdk-constructs + +Before reviewing, read all existing comments on the PR to understand what has already been discussed. Do not repeat or re-post issues that have already been raised in existing comments. + +Review the PR. If there are any serious issues that require code changes before merging, post a comment on the PR for each issue explaining the problem. If there are multiple ways to fix an issue, list the options so the author can choose. Skip style nits and minor suggestions — only flag things that actually need to change. + +If all serious issues have already been raised in existing comments, or if you found no new issues, post a single comment on the PR saying it looks good to merge (or that all issues have already been flagged). diff --git a/.github/scripts/prompts/system.md b/.github/scripts/prompts/system.md new file mode 100644 index 000000000..963accb8a --- /dev/null +++ b/.github/scripts/prompts/system.md @@ -0,0 +1,21 @@ +# AgentCore CLI Development Workspace + +This workspace contains two repos for developing and testing the AgentCore CLI. + +## Repositories + +### agentcore-cli/ (`aws/agentcore-cli`) + +The terminal experience for creating, developing, and deploying AI agents to AgentCore. Node.js/TypeScript CLI built with Ink (React-based TUI). + +### agentcore-l3-cdk-constructs/ (`aws/agentcore-l3-cdk-constructs`) + +AWS CDK L3 constructs for declaring and deploying AgentCore infrastructure. Used by agentcore-cli to vend CDK projects when users run `agentcore create`. + +## How they relate + +`agentcore-cli` is the main product. It vends CDK projects using constructs from `agentcore-l3-cdk-constructs`. + +## Testing with a bundled distribution + +Run `npm run bundle` in `agentcore-cli/` to create a tar distribution that includes the packaged `agentcore-l3-cdk-constructs`. You can then install it globally with `npm install -g ` to test the CLI end-to-end. diff --git a/.github/scripts/python/harness_review.py b/.github/scripts/python/harness_review.py new file mode 100644 index 000000000..fbfd0b0f9 --- /dev/null +++ b/.github/scripts/python/harness_review.py @@ -0,0 +1,217 @@ +"""Invoke Bedrock AgentCore Harness to review a GitHub PR. + +Reads PR_URL from the environment. Streams harness output to stdout. +Uses raw HTTP with SigV4 signing — no custom service model needed. +""" + +import json +import os +import sys +import time +import uuid + +import boto3 +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest +from botocore.eventstream import EventStreamBuffer +from urllib.parse import quote +import urllib3 + +# ANSI color codes +CYAN = "\033[36m" +YELLOW = "\033[33m" +GREEN = "\033[32m" +RED = "\033[31m" +DIM = "\033[2m" +RESET = "\033[0m" + +SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..") + + +def read_prompt(filename): + """Read a prompt template from the prompts directory.""" + path = os.path.join(SCRIPTS_DIR, "prompts", filename) + with open(path) as f: + return f.read() + + +def invoke_harness(harness_arn, body, region): + """Send a SigV4-signed request to the harness invoke endpoint. Returns a streaming response. + + InvokeHarness is not in standard boto3, so we call the REST API directly. + boto3 is only used to resolve AWS credentials (from env vars, OIDC, etc.) + and sign the request with SigV4. The response is an AWS binary event stream. + """ + session = boto3.Session(region_name=region) + credentials = session.get_credentials().get_frozen_credentials() + url = f"https://bedrock-agentcore.{region}.amazonaws.com/harnesses/invoke?harnessArn={quote(harness_arn, safe='')}" + request = AWSRequest(method="POST", url=url, data=body, headers={ + "Content-Type": "application/json", + "Accept": "application/vnd.amazon.eventstream", + }) + SigV4Auth(credentials, "bedrock-agentcore", region).add_auth(request) + return urllib3.PoolManager().urlopen( + "POST", url, body=body, + headers=dict(request.headers), + preload_content=False, + timeout=urllib3.Timeout(connect=10, read=600), + ) + + +def parse_events(http_response): + """Yield (event_type, payload) tuples from the harness binary event stream. + + The response arrives as raw bytes in AWS binary event stream format. + EventStreamBuffer reassembles complete events from the 4KB chunks, + and we decode each event's JSON payload before yielding it. + """ + event_buffer = EventStreamBuffer() + for chunk in http_response.stream(4096): + event_buffer.add_data(chunk) + for event in event_buffer: + if event.headers.get(":message-type") == "exception": + payload = json.loads(event.payload.decode("utf-8")) + print(f"\n{RED}ERROR: {payload}{RESET}", file=sys.stderr) + sys.exit(1) + event_type = event.headers.get(":event-type", "") + if event.payload: + yield event_type, json.loads(event.payload.decode("utf-8")) + + +def print_stream(http_response): + """Display harness events with GitHub Actions log groups. + + The harness streams events as the agent works: + contentBlockStart — a new block begins (text or tool call) + contentBlockDelta — incremental chunks of text or tool input JSON + contentBlockStop — block complete, we now have full tool input to display + messageStop — agent finished + internalServerException — server error + + Tool calls are wrapped in ::group::/::endgroup:: for collapsible sections + in the GitHub Actions log UI. Agent reasoning text is printed inline in dim. + """ + start_time = time.time() + iteration = 0 + tool_name = None + tool_input = "" + tool_start = 0.0 + in_group = False + text_buffer = "" + + def close_group(): + nonlocal in_group + if in_group: + print("::endgroup::", flush=True) + in_group = False + + def flush_text(): + nonlocal text_buffer + if text_buffer: + for line in text_buffer.splitlines(): + print(f"{DIM}{line}{RESET}", flush=True) + text_buffer = "" + + for event_type, payload in parse_events(http_response): + + if event_type == "contentBlockStart": + start = payload.get("start", {}) + if "toolUse" in start: + tool_name = start["toolUse"].get("name", "unknown") + tool_input = "" + tool_start = time.time() + iteration += 1 + + elif event_type == "contentBlockDelta": + delta = payload.get("delta", {}) + if "text" in delta: + close_group() + text_buffer += delta["text"] + if "toolUse" in delta: + tool_input += delta["toolUse"].get("input", "") + + elif event_type == "contentBlockStop": + flush_text() + if tool_name: + elapsed = time.time() - tool_start + try: + parsed = json.loads(tool_input) + except (json.JSONDecodeError, TypeError): + parsed = tool_input + + close_group() + + cmd = parsed.get("command") if isinstance(parsed, dict) else None + header = f"{CYAN}[{iteration}]{RESET} {YELLOW}{tool_name}{RESET} {DIM}({elapsed:.1f}s){RESET}" + if cmd: + header += f": $ {cmd}" + + print(f"::group::{header}", flush=True) + in_group = True + + if isinstance(parsed, dict): + for k, v in parsed.items(): + if k != "command": + print(f" {DIM}{k}:{RESET} {str(v)[:300]}", flush=True) + + tool_name = None + tool_input = "" + + elif event_type == "messageStop": + flush_text() + close_group() + if payload.get("stopReason") == "end_turn": + total = time.time() - start_time + print(f"\n\n{GREEN}{'=' * 50}", flush=True) + print(f" Done ({int(total // 60)}m {int(total % 60)}s)", flush=True) + print(f"{'=' * 50}{RESET}", flush=True) + + elif event_type == "internalServerException": + close_group() + print(f"\n{RED}ERROR: {payload}{RESET}", file=sys.stderr) + sys.exit(1) + + close_group() + total = time.time() - start_time + print(f"\n{GREEN}Review complete.{RESET} {DIM}({iteration} tool calls, {int(total)}s total){RESET}") + + +# --- Main --- + +# All config comes from environment variables (set via GitHub secrets/workflow) +MODEL_ID = os.environ.get("HARNESS_MODEL_ID", "us.anthropic.claude-opus-4-7") +HARNESS_ARN = os.environ.get("HARNESS_ARN", "") +PR_URL = os.environ.get("PR_URL", "") + +for name, val in [("HARNESS_ARN", HARNESS_ARN), ("PR_URL", PR_URL)]: + if not val: + print(f"{RED}ERROR: {name} environment variable is required{RESET}", file=sys.stderr) + sys.exit(1) + +# Extract region from the ARN (arn:aws:bedrock-agentcore:{region}:{account}:harness/{id}) +REGION = HARNESS_ARN.split(":")[3] +SESSION_ID = str(uuid.uuid4()).upper() + +print(f"{CYAN}Session:{RESET} {SESSION_ID}") +print(f"{CYAN}PR:{RESET} {PR_URL}") +print(f"{CYAN}Harness:{RESET} {HARNESS_ARN}") +print() + +SYSTEM_PROMPT = read_prompt("system.md") +REVIEW_PROMPT = read_prompt("review.md").format(pr_url=PR_URL) + +request_body = json.dumps({ + "runtimeSessionId": SESSION_ID, + "systemPrompt": [{"text": SYSTEM_PROMPT}], + "messages": [{"role": "user", "content": [{"text": REVIEW_PROMPT}]}], + "model": {"bedrockModelConfig": {"modelId": MODEL_ID}}, +}) + +http_response = invoke_harness(HARNESS_ARN, request_body, REGION) + +if http_response.status != 200: + error = http_response.read().decode("utf-8") + print(f"{RED}ERROR: HTTP {http_response.status}: {error}{RESET}", file=sys.stderr) + sys.exit(1) + +print_stream(http_response) diff --git a/.gitignore b/.gitignore index 3b00d92ae..6613a8f02 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,9 @@ ProtocolTesting/ # Auto-cloned CDK constructs (from scripts/bundle.mjs) .cdk-constructs-clone/ +.omc/ + +# Browser tests +browser-tests/.browser-test-env +browser-tests/test-results/ +browser-tests/playwright-report/ diff --git a/.prettierignore b/.prettierignore index b02529699..8eda17e39 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ CHANGELOG.md src/assets/**/*.md +.github/scripts/prompts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c13e90410..ec13d3ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [0.11.0] - 2026-04-24 + +### Added +- feat: add telemetry schemas and client (#941) (7c37fa6) +- feat: add GitHub Action for automated PR review via AgentCore Harness (#934) (a365bf5) + +### Fixed +- fix: display session ID after CLI invoke completes (#957) (51e4a8e) +- fix: lower eventExpiryDuration minimum from 7 to 3 days (closes #744) (#956) (8613657) +- fix: use pull_request_target for fork PR support (#958) (933bac8) +- fix: agentcore dev not working in windows (#951) (5271f55) +- fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs (#949) (c30ed54) +- fix: allow code-based evaluators in online eval configs (#947) (3d2d671) +- fix: buffer streaming text to avoid per-token log lines in GitHub Actions (#946) (cb1e81a) + +### Other Changes +- test: add browser tests for agent inspector (#938) (7a4104d) + ## [0.10.0] - 2026-04-23 ### Added diff --git a/browser-tests/constants.ts b/browser-tests/constants.ts new file mode 100644 index 000000000..7ef4d4848 --- /dev/null +++ b/browser-tests/constants.ts @@ -0,0 +1,3 @@ +import { join } from 'node:path'; + +export const ENV_FILE = join(__dirname, '.browser-test-env'); diff --git a/browser-tests/fixtures.ts b/browser-tests/fixtures.ts new file mode 100644 index 000000000..b6aa7b601 --- /dev/null +++ b/browser-tests/fixtures.ts @@ -0,0 +1,56 @@ +import { ENV_FILE } from './constants'; +import { type Page, test as base, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; + +interface BrowserTestEnv { + projectPath: string; + port: number; + projectName: string; +} + +function readTestEnv(): BrowserTestEnv { + const raw = readFileSync(ENV_FILE, 'utf-8'); + const parsed: Record = {}; + for (const line of raw.split('\n')) { + const match = line.match(/^(\w+)=(.+)$/); + if (match) parsed[match[1]!] = match[2]!; + } + return { + projectPath: parsed.PROJECT_PATH!, + port: Number(parsed.PORT), + projectName: parsed.PROJECT_NAME!, + }; +} + +export const test = base.extend<{ testEnv: BrowserTestEnv }>({ + testEnv: async ({}, use) => { + await use(readTestEnv()); + }, +}); + +/** + * Send a chat message and wait for the agent to finish responding. + * Returns the assistant message locator. + */ +export async function sendMessage(page: Page, text: string) { + const chatInput = page.getByTestId('chat-input'); + await expect(chatInput).toBeEnabled({ timeout: 60_000 }); + + const messageList = page.getByTestId('message-list'); + const existingCount = await messageList.getByTestId(/^chat-message-/).count(); + + await chatInput.fill(text); + await page.getByRole('button', { name: 'Send message' }).click(); + + const assistantMessage = messageList.getByTestId(`chat-message-${existingCount + 1}`); + await expect(assistantMessage).toBeVisible({ timeout: 60_000 }); + await expect(assistantMessage).not.toContainText('ECONNREFUSED'); + + // Wait for streaming to complete so the agent is idle for subsequent tests. + await chatInput.fill('.'); + await expect(page.getByRole('button', { name: 'Send message' })).toBeEnabled({ timeout: 30_000 }); + + return assistantMessage; +} + +export { expect }; diff --git a/browser-tests/global-setup.ts b/browser-tests/global-setup.ts new file mode 100644 index 000000000..462cfa260 --- /dev/null +++ b/browser-tests/global-setup.ts @@ -0,0 +1,144 @@ +import { ENV_FILE } from './constants'; +import * as pty from 'node-pty'; +import { type ExecSyncOptions, execSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { createWriteStream, mkdirSync, writeFileSync } from 'node:fs'; +import { createConnection } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const CLI_PATH = join(__dirname, '..', 'dist', 'cli', 'index.mjs'); +const PTY_LOG = join(__dirname, 'test-results', 'agentcore-dev-pty.log'); + +function hasAwsCredentials(): boolean { + try { + execSync('aws sts get-caller-identity', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function hasCommand(cmd: string): boolean { + try { + execSync(`which ${cmd}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +async function waitForServerReady(port: number, timeoutMs = 90000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const listening = await new Promise(resolve => { + const socket = createConnection({ port, host: '127.0.0.1' }, () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => { + socket.destroy(); + resolve(false); + }); + }); + if (listening) return true; + await new Promise(resolve => setTimeout(resolve, 500)); + } + return false; +} + +export default async function globalSetup() { + const missing: string[] = []; + if (!hasAwsCredentials()) missing.push('AWS credentials (run `aws sts get-caller-identity`)'); + if (!hasCommand('uv')) missing.push('`uv` on PATH'); + + if (missing.length > 0) { + if (process.env.CI) { + throw new Error(`Browser tests require: ${missing.join(', ')}`); + } + console.log(`\nSkipping browser tests — missing: ${missing.join(', ')}\n`); + process.exit(0); + } + + const testDir = join(tmpdir(), `agentcore-browser-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + + const projectName = `BrTest${String(Date.now()).slice(-8)}`; + + console.log(`\nCreating test project "${projectName}" in ${testDir}`); + + const cleanEnv = { ...process.env }; + delete cleanEnv.INIT_CWD; + + const execOpts: ExecSyncOptions = { cwd: testDir, stdio: 'pipe', env: cleanEnv }; + + let createRaw: string; + try { + createRaw = execSync( + `node ${CLI_PATH} create --name ${projectName} --language Python --framework Strands --model-provider Bedrock --memory none --json`, + execOpts + ).toString(); + } catch (err: unknown) { + const e = err as { stderr?: Buffer; stdout?: Buffer; status?: number }; + const stderr = e.stderr?.toString() ?? ''; + const stdout = e.stdout?.toString() ?? ''; + throw new Error(`agentcore create failed (exit ${e.status}):\nstdout: ${stdout}\nstderr: ${stderr}`); + } + + // eslint-disable-next-line no-control-regex + const createResult = createRaw.replace(/\x1B\[\??\d*[a-zA-Z]/g, '').trim(); + const parsed = JSON.parse(createResult.split('\n').pop()!); + const projectPath: string = resolve(testDir, parsed.projectPath); + + console.log(`Project created at ${projectPath}`); + console.log(`Starting agentcore dev...`); + + const env = { ...process.env }; + delete env.INIT_CWD; + if (env.AGENT_INSPECTOR_PATH) { + env.AGENT_INSPECTOR_PATH = resolve(env.AGENT_INSPECTOR_PATH); + } + + const ptyProcess = pty.spawn('node', [CLI_PATH, 'dev'], { + cwd: projectPath, + env, + cols: 80, + rows: 24, + }); + + mkdirSync(join(__dirname, 'test-results'), { recursive: true }); + // eslint-disable-next-line no-control-regex + const stripAnsi = (s: string) => s.replace(/\x1B\[\??[\d;]*[a-zA-Z]/g, ''); + const ptyLog = createWriteStream(PTY_LOG); + + let serverOutput = ''; + const webUIPort = await new Promise((resolvePort, reject) => { + const timeout = setTimeout(() => { + ptyProcess.kill(); + reject(new Error(`agentcore dev failed to start within timeout.\nOutput: ${serverOutput}`)); + }, 90000); + + ptyProcess.onData((data: string) => { + serverOutput += data; + ptyLog.write(stripAnsi(data)); + const match = serverOutput.match(/Chat UI: http:\/\/localhost:(\d+)/); + if (match) { + clearTimeout(timeout); + resolvePort(parseInt(match[1]!, 10)); + } + }); + }); + + const ready = await waitForServerReady(webUIPort); + if (!ready) { + ptyProcess.kill(); + throw new Error(`Web UI reported port ${webUIPort} but it is not responding.\nOutput: ${serverOutput}`); + } + + console.log(`Dev server ready on port ${webUIPort}`); + + writeFileSync( + ENV_FILE, + `PROJECT_PATH=${projectPath}\nPORT=${webUIPort}\nTEST_DIR=${testDir}\nSERVER_PID=${ptyProcess.pid}\nPROJECT_NAME=${projectName}\n` + ); +} diff --git a/browser-tests/global-teardown.ts b/browser-tests/global-teardown.ts new file mode 100644 index 000000000..be3ad64bf --- /dev/null +++ b/browser-tests/global-teardown.ts @@ -0,0 +1,39 @@ +import { ENV_FILE } from './constants'; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; + +export default async function globalTeardown() { + if (!existsSync(ENV_FILE)) return; + + const raw = readFileSync(ENV_FILE, 'utf-8'); + + const serverPid = raw.match(/^SERVER_PID=(.+)$/m)?.[1]; + if (serverPid) { + try { + process.kill(Number(serverPid), 'SIGTERM'); + console.log(`\nStopped dev server (PID ${serverPid})`); + } catch { + // Process already exited + } + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + const projectPath = raw.match(/^PROJECT_PATH=(.+)$/m)?.[1]; + const testDir = raw.match(/^TEST_DIR=(.+)$/m)?.[1]; + + if (projectPath) { + const logsDir = join(projectPath, 'agentcore', '.cli', 'logs'); + const outputDir = join(__dirname, 'test-results', 'dev-server-logs'); + if (existsSync(logsDir)) { + mkdirSync(outputDir, { recursive: true }); + cpSync(logsDir, outputDir, { recursive: true }); + } + } + + if (testDir && existsSync(testDir)) { + console.log(`Cleaning up ${testDir}`); + rmSync(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + } + + unlinkSync(ENV_FILE); +} diff --git a/browser-tests/playwright.config.ts b/browser-tests/playwright.config.ts new file mode 100644 index 000000000..988b0004f --- /dev/null +++ b/browser-tests/playwright.config.ts @@ -0,0 +1,33 @@ +import { ENV_FILE } from './constants'; +import { defineConfig, devices } from '@playwright/test'; +import { readFileSync } from 'node:fs'; + +function getPort(): number { + try { + const raw = readFileSync(ENV_FILE, 'utf-8'); + const match = raw.match(/^PORT=(\d+)$/m); + if (match) return parseInt(match[1]!, 10); + } catch {} + return 8081; +} + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + workers: 1, + timeout: 120_000, + retries: process.env.CI ? 1 : 0, + outputDir: './test-results', + reporter: [['html', { open: 'never', outputFolder: './playwright-report' }]], + + globalSetup: './global-setup.ts', + globalTeardown: './global-teardown.ts', + + use: { + baseURL: `http://localhost:${getPort()}`, + trace: process.env.PLAYWRIGHT_TRACE === 'off' ? 'off' : 'retain-on-failure', + screenshot: 'only-on-failure', + }, + + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/browser-tests/tests/chat-invocation.test.ts b/browser-tests/tests/chat-invocation.test.ts new file mode 100644 index 000000000..fb82b39a0 --- /dev/null +++ b/browser-tests/tests/chat-invocation.test.ts @@ -0,0 +1,10 @@ +import { expect, sendMessage, test } from '../fixtures'; + +test.describe('Chat invocation', () => { + test('send a message and receive a response', async ({ page }) => { + await page.goto('/'); + + const assistantMessage = await sendMessage(page, 'What is 2 plus 2? Reply with just the number.'); + await expect(assistantMessage).not.toBeEmpty(); + }); +}); diff --git a/browser-tests/tests/inspector-loads.test.ts b/browser-tests/tests/inspector-loads.test.ts new file mode 100644 index 000000000..649a3edb4 --- /dev/null +++ b/browser-tests/tests/inspector-loads.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from '../fixtures'; + +test.describe('Inspector loads', () => { + test('page renders and shows the agent', async ({ page, testEnv }) => { + await page.goto('/'); + + await expect(page.locator('header')).toBeVisible(); + + const agentStatus = page.getByTestId('agent-status'); + await expect(agentStatus).toBeVisible({ timeout: 30_000 }); + await expect(agentStatus).toContainText(testEnv.projectName); + }); +}); diff --git a/browser-tests/tests/resources.test.ts b/browser-tests/tests/resources.test.ts new file mode 100644 index 000000000..87e06c1c5 --- /dev/null +++ b/browser-tests/tests/resources.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from '../fixtures'; + +test.describe('Resources', () => { + test('resource panel shows the agent', async ({ page, testEnv }) => { + await page.goto('/'); + + const resourcePanel = page.getByTestId('resource-panel'); + await expect(resourcePanel).toBeVisible({ timeout: 10_000 }); + + const resourcesTab = resourcePanel.getByRole('tab', { name: 'Resources' }); + await resourcesTab.click(); + + const agentNode = resourcePanel.getByRole('button', { name: new RegExp(`agent: ${testEnv.projectName}`, 'i') }); + await expect(agentNode).toBeVisible({ timeout: 10_000 }); + + await page.getByRole('button', { name: 'Toggle resource panel' }).click(); + await expect(resourcePanel).not.toBeVisible(); + }); +}); diff --git a/browser-tests/tests/start-agent.test.ts b/browser-tests/tests/start-agent.test.ts new file mode 100644 index 000000000..a18c39747 --- /dev/null +++ b/browser-tests/tests/start-agent.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '../fixtures'; + +test.describe('Start agent', () => { + test('agent starts and shows running status', async ({ page }) => { + await page.goto('/'); + + const agentStatus = page.getByTestId('agent-status'); + await expect(agentStatus).toBeVisible({ timeout: 30_000 }); + + const chatInput = page.getByTestId('chat-input'); + await expect(chatInput).toBeVisible({ timeout: 60_000 }); + await expect(chatInput).toBeEnabled({ timeout: 60_000 }); + + await expect(page.getByText('Error')).not.toBeVisible(); + }); +}); diff --git a/browser-tests/tests/traces.test.ts b/browser-tests/tests/traces.test.ts new file mode 100644 index 000000000..77c1b5ad4 --- /dev/null +++ b/browser-tests/tests/traces.test.ts @@ -0,0 +1,22 @@ +import { expect, sendMessage, test } from '../fixtures'; + +test.describe('Traces', () => { + test('traces panel shows trace after invocation', async ({ page }) => { + await page.goto('/'); + + await sendMessage(page, 'Say hello'); + + await page.getByRole('tab', { name: 'Traces' }).click(); + + const traceList = page.getByTestId('trace-list'); + await expect(traceList).toBeVisible({ timeout: 30_000 }); + + const traceButton = traceList.getByRole('button').first(); + await expect(traceButton).toBeVisible({ timeout: 30_000 }); + + await traceButton.click(); + + const spanRow = page.locator('[role="button"]').filter({ hasText: /.+/ }); + await expect(spanRow.first()).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/docs/TESTING.md b/docs/TESTING.md index 30889b17a..700ab3aae 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -3,11 +3,12 @@ ## Quick Start ```bash -npm test # Run unit tests -npm run test:watch # Run tests in watch mode -npm run test:integ # Run integration tests -npm run test:tui # Run TUI integration tests (builds first) -npm run test:all # Run all tests (unit + integ) +npm test # Run unit tests +npm run test:watch # Run tests in watch mode +npm run test:integ # Run integration tests +npm run test:tui # Run TUI integration tests (builds first) +npm run test:browser # Run browser tests (requires AWS creds, uv, agentcore) +npm run test:all # Run all tests (unit + integ) ``` ## Test Organization @@ -347,6 +348,63 @@ it('provides diagnostics in LaunchError', async () => { }); ``` +## Browser Tests + +Browser tests use Playwright to test the web UI (agent inspector) served by `agentcore dev`. + +### Prerequisites + +- AWS credentials configured (`aws sts get-caller-identity` must succeed) +- `uv` on PATH +- Local build (`npm run build`) +- Playwright browsers installed: `npx playwright install chromium` + +### Running + +```bash +npm run test:browser +``` + +Test results and the HTML report are written to `browser-tests/test-results/` and `browser-tests/playwright-report/` +respectively. To view the report: + +```bash +npx playwright show-report browser-tests/playwright-report +``` + +By default, tests run against the `@aws/agent-inspector` package from npm (in `node_modules`). + +### Testing against a local agent-inspector build + +To test with a local checkout of the agent-inspector (e.g. when developing new UI features or adding test IDs): + +1. Clone `agent-inspector` as a sibling directory and build it +2. Run with `AGENT_INSPECTOR_PATH`: + +```bash +AGENT_INSPECTOR_PATH=../agent-inspector/dist-assets npm run test:browser +``` + +### Test structure + +``` +browser-tests/ +├── playwright.config.ts # Playwright configuration +├── global-setup.ts # Creates test project, starts agentcore dev +├── global-teardown.ts # Stops dev server, cleans up temp files +├── constants.ts # Shared constants (env file path) +├── fixtures.ts # Custom test fixtures (testEnv with port, project path) +└── tests/ # Test files + ├── chat-invocation.test.ts + ├── inspector-loads.test.ts + ├── resources.test.ts + ├── start-agent.test.ts + └── traces.test.ts +``` + +The global setup creates a temporary project via `agentcore create`, starts `agentcore dev`, and writes connection +details to an env file. Tests read the env file via the `testEnv` fixture. + ## Configuration Test configuration is in `vitest.config.ts` using Vitest projects: diff --git a/docs/commands.md b/docs/commands.md index f2ea60a0e..f6f15b9ae 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -525,6 +525,11 @@ agentcore invoke --runtime MyAgent --target staging agentcore invoke --session-id abc123 # Continue session agentcore invoke --json # JSON output +# Long prompts: read from a file or pipe from stdin +agentcore invoke --prompt-file prompt.json --json +cat long-prompt.txt | agentcore invoke --json +jq -r '.response' result.json | agentcore invoke --json + # MCP protocol invoke agentcore invoke call-tool --tool myTool --input '{"key": "value"}' @@ -534,22 +539,28 @@ agentcore invoke --exec "python script.py" --timeout 120 agentcore invoke --exec "cat /etc/os-release" --json ``` -| Flag | Description | -| --------------------- | -------------------------------------------------------- | -| `[prompt]` | Prompt text (positional argument) | -| `--prompt ` | Prompt text (flag, takes precedence over positional) | -| `--runtime ` | Specific runtime | -| `--target ` | Deployment target | -| `--session-id ` | Continue a specific session | -| `--user-id ` | User ID for runtime invocation (default: `default-user`) | -| `--stream` | Stream response in real-time | -| `--tool ` | MCP tool name (use with `call-tool` prompt) | -| `--input ` | MCP tool arguments as JSON (use with `--tool`) | -| `-H, --header ` | Custom header (`"Name: Value"`, repeatable) | -| `--bearer-token ` | Bearer token for CUSTOM_JWT auth | -| `--exec` | Execute a shell command in the runtime container | -| `--timeout ` | Timeout in seconds for `--exec` commands | -| `--json` | JSON output | +The prompt can come from four sources, resolved in this precedence order: `--prompt` > positional > `--prompt-file` > +piped stdin. `--prompt-file` combined with piped stdin content returns a collision error — pick one. + +| Flag | Description | +| ---------------------- | ---------------------------------------------------------------- | +| `[prompt]` | Prompt text (positional argument) | +| `--prompt ` | Prompt text (flag, takes precedence over positional) | +| `--prompt-file ` | Read the prompt from a file (useful for long / structured input) | +| `--runtime ` | Specific runtime | +| `--target ` | Deployment target | +| `--session-id ` | Continue a specific session | +| `--user-id ` | User ID for runtime invocation (default: `default-user`) | +| `--stream` | Stream response in real-time | +| `--tool ` | MCP tool name (use with `call-tool` prompt) | +| `--input ` | MCP tool arguments as JSON (use with `--tool`) | +| `-H, --header ` | Custom header (`"Name: Value"`, repeatable) | +| `--bearer-token ` | Bearer token for CUSTOM_JWT auth | +| `--exec` | Execute a shell command in the runtime container | +| `--timeout ` | Timeout in seconds for `--exec` commands | +| `--json` | JSON output | + +Piped stdin is auto-detected: when no prompt is supplied and stdin is not a TTY, the prompt is read from stdin. --- diff --git a/eslint.config.mjs b/eslint.config.mjs index 72990753f..5111978cb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -143,7 +143,7 @@ export default tseslint.config( prettier, // Relaxed rules for test files { - files: ['**/*.test.ts', '**/*.test.tsx', '**/test-utils/**', 'integ-tests/**'], + files: ['**/*.test.ts', '**/*.test.tsx', '**/test-utils/**', 'integ-tests/**', 'browser-tests/**'], rules: { 'partition/no-hardcoded-arn-partition': 'off', 'partition/no-hardcoded-endpoint-tld': 'off', @@ -154,6 +154,10 @@ export default tseslint.config( '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/prefer-nullish-coalescing': 'off', + '@typescript-eslint/prefer-regexp-exec': 'off', + 'no-empty-pattern': 'off', + 'no-empty': 'off', + 'react-hooks/rules-of-hooks': 'off', }, }, { diff --git a/package-lock.json b/package-lock.json index e7aada6c1..0bdaa8bd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.10.0", + "version": "0.11.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -26,8 +26,11 @@ "@aws-sdk/credential-providers": "^3.893.0", "@aws/agent-inspector": "0.2.1", "@commander-js/extra-typings": "^14.0.0", - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", "@opentelemetry/otlp-transformer": "^0.213.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-metrics": "^2.6.1", "@smithy/shared-ini-file-loader": "^4.4.2", "commander": "^14.0.2", "dotenv": "^17.2.3", @@ -48,7 +51,8 @@ "@aws-sdk/client-cognito-identity-provider": "^3.1018.0", "@eslint/js": "^9.39.2", "@modelcontextprotocol/sdk": "^1.0.0", - "@secretlint/secretlint-rule-preset-recommend": "^11.3.0", + "@playwright/test": "^1.59.1", + "@secretlint/secretlint-rule-preset-recommend": "^12.2.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/js-yaml": "^4.0.9", "@types/node": "^25.0.3", @@ -59,7 +63,7 @@ "@xterm/headless": "^6.0.0", "aws-cdk-lib": "^2.248.0", "constructs": "^10.4.4", - "esbuild": "^0.27.2", + "esbuild": "^0.28.0", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -73,7 +77,7 @@ "lint-staged": "^16.2.7", "node-pty": "^1.1.0", "prettier": "^3.7.4", - "secretlint": "^11.3.0", + "secretlint": "^12.2.0", "tsx": "^4.21.0", "typescript": "^5", "typescript-eslint": "^8.50.1", @@ -144,32 +148,33 @@ "license": "Apache-2.0" }, "node_modules/@aws-cdk/aws-service-spec": { - "version": "0.1.173", - "resolved": "https://registry.npmjs.org/@aws-cdk/aws-service-spec/-/aws-service-spec-0.1.173.tgz", - "integrity": "sha512-dJNMTUgl43DoBbMWugSMWzuowtyFe2nfOqjbx33FMOKDMmgArokvpkcV/F+/zRtHPg3Byqg/mwEsptoXCMzkxQ==", + "version": "0.1.166", + "resolved": "https://registry.npmjs.org/@aws-cdk/aws-service-spec/-/aws-service-spec-0.1.166.tgz", + "integrity": "sha512-I5Dt7/39kLxBbG5hIpLKW+meOZPO0o7DX0dKfWqOFMYaYrsIC9thM4LT5T9f4D1vG/DTx/6LyF8e8a7donzKbg==", "license": "Apache-2.0", "dependencies": { - "@aws-cdk/service-spec-types": "^0.0.239", + "@aws-cdk/service-spec-types": "^0.0.232", "@cdklabs/tskb": "^0.0.4" } }, "node_modules/@aws-cdk/aws-service-spec/node_modules/@aws-cdk/service-spec-types": { - "version": "0.0.239", - "resolved": "https://registry.npmjs.org/@aws-cdk/service-spec-types/-/service-spec-types-0.0.239.tgz", - "integrity": "sha512-qKp97XZ4wYvAhsPcD/Fa/jImxdp7AAUDPSB7jDZ/0YqyvZ+770XDSonju978BZqjP/UZ4PjsOuVpTAw+lBsdmA==", + "version": "0.0.232", + "resolved": "https://registry.npmjs.org/@aws-cdk/service-spec-types/-/service-spec-types-0.0.232.tgz", + "integrity": "sha512-GcXA6PJw8fYb3anh8nFzrkPMd1728r2pxeWK21luUCDHgDCqOhqBSmvGbt/s8wH/lxI9CtQgDO+BEWhqEFwSCg==", "license": "Apache-2.0", "dependencies": { "@cdklabs/tskb": "^0.0.4" } }, "node_modules/@aws-cdk/cdk-assets-lib": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@aws-cdk/cdk-assets-lib/-/cdk-assets-lib-1.4.3.tgz", - "integrity": "sha512-6Xcst2JyVAK/DJt1OdE7HrXDoY8fM5ddkG8lGygtMYkjQbk+MQNxukeKthYy94uhn7t6dsPAb070cJtUJEU3Yw==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@aws-cdk/cdk-assets-lib/-/cdk-assets-lib-1.4.2.tgz", + "integrity": "sha512-RFDITy2nC9LPckAVymApxtLl9wzI0arUh/2h9d2LAjl2zeiVASr89WpRK/bA1RhHw3ElypEMa052DIAzt3RimA==", "license": "Apache-2.0", "dependencies": { - "@aws-cdk/cloud-assembly-api": "2.2.2", - "@aws-cdk/cloud-assembly-schema": ">=53.15.0", + "@aws-cdk/cloud-assembly-api": "2.2.1", + "@aws-cdk/cloud-assembly-schema": ">=53.8.0", + "@aws-cdk/cx-api": "^2", "@aws-sdk/client-ecr": "^3", "@aws-sdk/client-s3": "^3", "@aws-sdk/client-secrets-manager": "^3", @@ -181,16 +186,16 @@ "archiver": "^7.0.1", "fast-glob": "^3.3.3", "mime": "^2", - "picomatch": "^4.0.4" + "picomatch": "^4.0.3" }, "engines": { "node": ">= 18.0.0" } }, "node_modules/@aws-cdk/cli-plugin-contract": { - "version": "2.182.2", - "resolved": "https://registry.npmjs.org/@aws-cdk/cli-plugin-contract/-/cli-plugin-contract-2.182.2.tgz", - "integrity": "sha512-M1G52uA2JZPErOIKAkr0cjty7Y+/g99KHH7p9ZQv5jpZG7PNhtIraufbgBAo3iCEAH38SDtiPJL3+Ac8EttrIQ==", + "version": "2.182.1", + "resolved": "https://registry.npmjs.org/@aws-cdk/cli-plugin-contract/-/cli-plugin-contract-2.182.1.tgz", + "integrity": "sha512-T0VgSVe0uiOCLm79GD+kWba8T+dPRVixZW5eBSiT59h0dIOR7poqhm5lnjId/XPvetRd0tvqNLje6MXQ+3JWzw==", "license": "Apache-2.0", "peer": true, "engines": { @@ -198,9 +203,9 @@ } }, "node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-api/-/cloud-assembly-api-2.2.2.tgz", - "integrity": "sha512-iiypKqfpHMqQ9z6Nwxx42Ha4NCevLVDQ8sphIbqHxSJE5kDe/DCzvh8b2HtlAshWjo44HMhYdfKNLR96S3T4sA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-api/-/cloud-assembly-api-2.2.1.tgz", + "integrity": "sha512-24ARpDQzF39UTickUgDH6RIs5otPG4aaKJZ93XUSNwiPSR9T+h7gXSF982+NZVYK+7SetQaqrVbm4lcF6dmXWw==", "bundleDependencies": [ "jsonschema", "semver" @@ -214,7 +219,7 @@ "node": ">= 18.0.0" }, "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=53.15.0" + "@aws-cdk/cloud-assembly-schema": ">=53.8.0" } }, "node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { @@ -237,9 +242,9 @@ } }, "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "53.16.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.16.0.tgz", - "integrity": "sha512-k9LJusrF2sxLN59QCAVj6PPmGMKtHAUWtfC7BraHJfE6DNp/9bHmh083Q3fyDNoE8lUd1Sakza5MBng9HYC1CQ==", + "version": "53.18.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.18.0.tgz", + "integrity": "sha512-/fa6rOpokkfa5tVIdhsaexQq5MVVTSsZSD1Tu45YcrdyGRusGrM9RlPMCPrwvMS1UfdVFBhcgO9dl9ODWAWOeQ==", "bundleDependencies": [ "jsonschema", "semver" @@ -273,15 +278,15 @@ } }, "node_modules/@aws-cdk/cloudformation-diff": { - "version": "2.187.1", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloudformation-diff/-/cloudformation-diff-2.187.1.tgz", - "integrity": "sha512-MvgW82OfGsqC8wbemVpk2xxuP6GaTxpcCD1uLhjuVAzwdzWL1YhfTizh4KHZW9szCQ1AZT37pw4qt45l5YNniA==", + "version": "2.186.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloudformation-diff/-/cloudformation-diff-2.186.0.tgz", + "integrity": "sha512-3NNyQHosDoFnEnOlU6SLg43uGuqR8NfoQpDw+nUL0OcXyrFOmQHV7pETxcwi9djvI1U0AHlwdKtjLnI4DwjNSA==", "license": "Apache-2.0", "dependencies": { - "@aws-cdk/aws-service-spec": "^0.1.171", - "@aws-cdk/service-spec-types": "^0.0.237", + "@aws-cdk/aws-service-spec": "^0.1.161", + "@aws-cdk/service-spec-types": "^0.0.227", "chalk": "^4", - "diff": "^8.0.4", + "diff": "^8.0.3", "fast-deep-equal": "^3.1.3", "string-width": "^4", "table": "^6" @@ -294,27 +299,27 @@ } }, "node_modules/@aws-cdk/cx-api": { - "version": "2.250.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cx-api/-/cx-api-2.250.0.tgz", - "integrity": "sha512-NdozKIixlMjY9P4BPb26T0TNyuKQBh9ZE/BDhlScD5MfndEiO4lBvbvGcqrhads7UFzCgdcfrXmNTKDY93LClQ==", + "version": "2.244.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cx-api/-/cx-api-2.244.0.tgz", + "integrity": "sha512-QE1BRNaxKe3+BbH9etBMdVen1AJ555O4R1l0s3CRTP66sx8FW6qYRi1JukquwkEmpf61Oi5fAUNRf8W0IGIoig==", "bundleDependencies": [ "semver", "@aws-cdk/cloud-assembly-api" ], "license": "Apache-2.0", "dependencies": { - "@aws-cdk/cloud-assembly-api": "^2.2.0", + "@aws-cdk/cloud-assembly-api": "^2.1.1", "semver": "^7.7.4" }, "engines": { "node": ">= 20.0.0" }, "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=53.0.0" + "@aws-cdk/cloud-assembly-schema": ">=52.1.0" } }, "node_modules/@aws-cdk/cx-api/node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.2.0", + "version": "2.1.1", "bundleDependencies": [ "jsonschema", "semver" @@ -323,13 +328,13 @@ "license": "Apache-2.0", "dependencies": { "jsonschema": "~1.4.1", - "semver": "^7.7.4" + "semver": "^7.7.3" }, "engines": { "node": ">= 18.0.0" }, "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=53.0.0" + "@aws-cdk/cloud-assembly-schema": ">=52.1.0" } }, "node_modules/@aws-cdk/cx-api/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { @@ -341,7 +346,7 @@ } }, "node_modules/@aws-cdk/cx-api/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": { - "version": "7.7.4", + "version": "7.7.3", "inBundle": true, "license": "ISC", "bin": { @@ -363,23 +368,23 @@ } }, "node_modules/@aws-cdk/service-spec-types": { - "version": "0.0.237", - "resolved": "https://registry.npmjs.org/@aws-cdk/service-spec-types/-/service-spec-types-0.0.237.tgz", - "integrity": "sha512-qC7dfSXGyJddKU/ys+G/LvmOvLmn+rSbKFa28Jk1eNAbE8d2mRu/Ul8srw4KE1QGI8QklR/xikAb4Xffw11z8Q==", + "version": "0.0.227", + "resolved": "https://registry.npmjs.org/@aws-cdk/service-spec-types/-/service-spec-types-0.0.227.tgz", + "integrity": "sha512-xQHO0xzItQN5mFxox1iX/0NxDkHMgCval2imn5uJeYh5BA6jwm5XkIVrtS4YaL0B3Eb3U3Q+tG8jK9fD/orGxg==", "license": "Apache-2.0", "dependencies": { "@cdklabs/tskb": "^0.0.4" } }, "node_modules/@aws-cdk/toolkit-lib": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/toolkit-lib/-/toolkit-lib-1.22.0.tgz", - "integrity": "sha512-ve7StKkRUj7qHwFrMffWobXTSHi4iaq0UnihQ44JOFCfhnkzIE5dekFPyn5mzSdIGf8XU/T9Hr86Xw3XD+qH0w==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/toolkit-lib/-/toolkit-lib-1.24.0.tgz", + "integrity": "sha512-tgtH0CJ8/N/CpT1/ebOBfUpxdAMSRsP9LTAjWfa+E0clX4Vuvx0w1J1bGYwtvKY9nQUbFIO4QfgNEHz8hVlMUA==", "license": "Apache-2.0", "dependencies": { "@aws-cdk/cdk-assets-lib": "^1", "@aws-cdk/cloud-assembly-api": "2.2.2", - "@aws-cdk/cloud-assembly-schema": ">=53.16.0", + "@aws-cdk/cloud-assembly-schema": ">=53.18.0", "@aws-cdk/cloudformation-diff": "^2", "@aws-cdk/cx-api": "^2", "@aws-sdk/client-appsync": "^3", @@ -431,6 +436,45 @@ "@aws-cdk/cli-plugin-contract": "^2" } }, + "node_modules/@aws-cdk/toolkit-lib/node_modules/@aws-cdk/cloud-assembly-api": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-api/-/cloud-assembly-api-2.2.2.tgz", + "integrity": "sha512-iiypKqfpHMqQ9z6Nwxx42Ha4NCevLVDQ8sphIbqHxSJE5kDe/DCzvh8b2HtlAshWjo44HMhYdfKNLR96S3T4sA==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.4" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "@aws-cdk/cloud-assembly-schema": ">=53.15.0" + } + }, + "node_modules/@aws-cdk/toolkit-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { + "version": "1.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@aws-cdk/toolkit-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": { + "version": "7.7.4", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@aws-cdk/toolkit-lib/node_modules/yaml": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", @@ -643,48 +687,48 @@ } }, "node_modules/@aws-sdk/client-application-signals": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-application-signals/-/client-application-signals-3.1031.0.tgz", - "integrity": "sha512-eRGLjSz5fnpXsPMDte79D6dtrmsEyPhBWP05SDpRchQxTCiCNIP7CNZnc12L11ZlfHO/dkfjyZtgGYbJN1vEWg==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-application-signals/-/client-application-signals-3.1037.0.tgz", + "integrity": "sha512-xnGVyIWU1SXNSnnARvU3U3sic0QWH0wek/X3WpXFCpOm0NBzbTablWiAszNDU9RCvg9KUDm6Wdp0T4jnodXhEg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -693,49 +737,49 @@ } }, "node_modules/@aws-sdk/client-appsync": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-appsync/-/client-appsync-3.1031.0.tgz", - "integrity": "sha512-1F/X4/XYMCZHbFXsUx6DlI7sXgu0j3IJCmlN52nLcy5lopZf8V0mgHNXBR9XhDWevjS8wE36BKACUp1fxMrTtw==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-appsync/-/client-appsync-3.1036.0.tgz", + "integrity": "sha512-UHrQGSZyEz4ID83lhnC299LYlOT/gUVo5Sfp060Z1mKgE5KcXXwfl0m7jbZ5FfUF/FmV++SOZrA6Ij1yYuM8XA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", - "@smithy/util-stream": "^4.5.23", + "@smithy/util-retry": "^4.3.4", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -744,49 +788,49 @@ } }, "node_modules/@aws-sdk/client-bedrock": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1031.0.tgz", - "integrity": "sha512-/zS58lZtmstR3cqGibPxxrWW5Ar4RDz9BDW/R+Qr524J52o+35W+nI+eSIsbj1b71mD8V4aZYMZlb3K7JRT5CA==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1037.0.tgz", + "integrity": "sha512-XGuJ86vuuEsqp0Gq8fMCSMd/VNCwqTvKwFT99SU2OOLyNp31ChZ+LdIckJZl/A3jpUyZYpXjn7IxP/N/6UFiZA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", - "@aws-sdk/token-providers": "3.1031.0", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/token-providers": "3.1037.0", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -795,48 +839,48 @@ } }, "node_modules/@aws-sdk/client-bedrock-agent": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agent/-/client-bedrock-agent-3.1031.0.tgz", - "integrity": "sha512-g5jUAG34Iglvad9EtwwPeKH6F6S/Omau/9uiom22MxS92s5IdY83+y0LSawYXCdl+vybD53nOJ1i8bn/RWIQfQ==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agent/-/client-bedrock-agent-3.1037.0.tgz", + "integrity": "sha512-8Uc3zdwfxmjMrXb4qP69bByL054R+jWNaW3/Hq93E1jnag8vZz8YhJlz1x6jr1oXOoOLVErc1L/g8x0sMFZuRA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -845,26 +889,26 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1031.0.tgz", - "integrity": "sha512-Q29W131eMyLSfVK9/jtxWC4JnTZ5QACWD1z8mCX7wzMlqppn/hH4XOwCJRmq0OPMJ3AdSgIpVFKQBj5rOGhGLQ==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1037.0.tgz", + "integrity": "sha512-8WmZulMmFnCWFuX2rDBoZdebCMmmrAi1VABsLgm4O73w3+s7tcON1YgspG9gTevuVRtOVdk1B6TLw2Mo8NBHSQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", @@ -872,25 +916,25 @@ "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", - "@smithy/util-stream": "^4.5.23", + "@smithy/util-retry": "^4.3.4", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -899,48 +943,48 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore-control": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1031.0.tgz", - "integrity": "sha512-JPg50WaGxoVI2YkJ+H0fNIGitWETAZ5/GQ7qlRgsF3McTW5h2nUY63mp8XjNtQiEsRUmwiS6jQ330vkNLB5Ufg==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1037.0.tgz", + "integrity": "sha512-tMfeMgohJ6L9ARRSdK8O7lbdYIggeRXtuRQFS+kISZTlvw+L4TjhUZ7TT5yIBO0UVkunoWsRQIH4VP4pgiVqQA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -950,30 +994,30 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1031.0.tgz", - "integrity": "sha512-ZgiSo2wslPXlv7wK4m2ULu2VfimbVRRlho0DqXhlvZGEqvtC209cMOxfPZWJ79Fz9sf0IzmWFkDtvMYjnwyLfw==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1037.0.tgz", + "integrity": "sha512-Evla4DUdBf1pQpQa7pbfquj7jRaRktkI0qGoWBJBXWB9wQISzJ8OEI4sHugk/W6SF47C7hMP/o3Z/XBrfnejCw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/middleware-websocket": "^3.972.16", - "@aws-sdk/region-config-resolver": "^3.972.12", - "@aws-sdk/token-providers": "3.1031.0", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/token-providers": "3.1037.0", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", @@ -981,25 +1025,25 @@ "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", - "@smithy/util-stream": "^4.5.23", + "@smithy/util-retry": "^4.3.4", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1007,49 +1051,85 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": { + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1037.0.tgz", + "integrity": "sha512-csxa484KboWLs3f8jFQ5v9RwH8FVf0fQ+SO3GSXyu4Jtinhh4qXmOWLSVX30RBpB933dZaKGHGEXzEEY88NqRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock/node_modules/@aws-sdk/token-providers": { + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1037.0.tgz", + "integrity": "sha512-csxa484KboWLs3f8jFQ5v9RwH8FVf0fQ+SO3GSXyu4Jtinhh4qXmOWLSVX30RBpB933dZaKGHGEXzEEY88NqRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-cloudcontrol": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudcontrol/-/client-cloudcontrol-3.1031.0.tgz", - "integrity": "sha512-ykJT+EYeQXhjwGGlZmah6P0NHhNIvCsyce3r5s7zPAUg6GYkjOh8racQ9pfBEOIgOfyub5jWXAYkB2LPxPGhFQ==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudcontrol/-/client-cloudcontrol-3.1036.0.tgz", + "integrity": "sha512-NabrgJpHzZtq4oIq7mHUtWhUgFPlgLQRxQpIoFTYXPEuGjpHqcxz9jYid9a8hCqjlAOUTbQwD/CbZqbBVA3LSg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -1059,48 +1139,48 @@ } }, "node_modules/@aws-sdk/client-cloudformation": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1031.0.tgz", - "integrity": "sha512-HnenmimuqVCLXMXhyZqb92aXT9P+Hm6nGyaFJP3yPsuR5wLQQIwg7xLITooE0wFaINzocUMhUOigd3EI3dJ5Dw==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1037.0.tgz", + "integrity": "sha512-nLSwtmayv7tjjp6t8Lc20xZCeA+XJ5UzXvauQCnO3aRZVAxrgarQntZjS+eWlRYGRqLBjXSre4xL7XwUlObb2A==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -1110,26 +1190,26 @@ } }, "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1031.0.tgz", - "integrity": "sha512-xfEzfhy+IhASKOm4QqjjsCECVoZWT6B6ei6WRvIf/l13w+UB719SAsWuT+h+Zh7wktnxIWcHLZN2d7HalWlrWw==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1037.0.tgz", + "integrity": "sha512-W0TRDfyBikNR+DzOTBgBLT4TqVHCAasqx2Xu4G4PfTRCansUtEJRydq0CEVOpHlMfme4Va89O/r9sp/VoDsKRg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", @@ -1137,24 +1217,24 @@ "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1163,48 +1243,48 @@ } }, "node_modules/@aws-sdk/client-codebuild": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-codebuild/-/client-codebuild-3.1031.0.tgz", - "integrity": "sha512-Ye+IAJgMa5H5zQkdafmbf5u7mSp+cfbU3B3zD5o3Ef5EiZ5J3QonkIE11/07j1dNcA68br8b1TasKutHjKH+Cg==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-codebuild/-/client-codebuild-3.1036.0.tgz", + "integrity": "sha512-9uD1Xiqn/BOvk2l5xwUAc5Ec6YLRMjTJf1c8jO9u1mI++iW+lpmCQKBrvK94fGAA778MUo2n/nVmSJubzCUVrA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1213,48 +1293,48 @@ } }, "node_modules/@aws-sdk/client-cognito-identity": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1031.0.tgz", - "integrity": "sha512-Tr13HNnBBLhag78gA/1kRekaw0BCkM2LVxT//ZFr51jNEhLGjrEU2iGjTiRlef898uedDiJ8I/m7cmSTitlUyw==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1037.0.tgz", + "integrity": "sha512-/BQAyz98JRQFg3E8de3fGGydIYnsFRd6Cla4+zkviOe641fLCG0ZkPIk9D22HSi8qy9XKx+zk6ed2PcLO8uuPw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1263,49 +1343,49 @@ } }, "node_modules/@aws-sdk/client-cognito-identity-provider": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1031.0.tgz", - "integrity": "sha512-P4+Jt5Ove035pIcQCeH0JIOVQvXscW0kbw54Foo0HReExR3DSDHaMmQ3IIMr6Q1Z+DKUlKbEBvW71tBEeI9Waw==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1037.0.tgz", + "integrity": "sha512-w0HuaMNtzcj6bErBX8/TVGbOz0a8JNCzPHLMq2u/ll4uuxl9Xut0njuy7vyY0/pYCuNE+no4uq+yHwn8U3ptgw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1314,49 +1394,49 @@ } }, "node_modules/@aws-sdk/client-ec2": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ec2/-/client-ec2-3.1031.0.tgz", - "integrity": "sha512-6IA461ToMENbE/MalbowWLx5UuUVvQlq0WF0r17KuEjaVJ1syjZLq1WTRjCn+1n3GKL1kMoXvlDq/9mSxPFWEA==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ec2/-/client-ec2-3.1036.0.tgz", + "integrity": "sha512-uX4Tob7pFxyuTXwnZ2ykFy+5JMAsmKHofpx0SiZV2AmfRtA2jvv1egSolSshACqR0eXJ32FI3EO3N50uGX1qTg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-sdk-ec2": "^3.972.20", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-sdk-ec2": "^3.972.22", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -1366,48 +1446,48 @@ } }, "node_modules/@aws-sdk/client-ecr": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1031.0.tgz", - "integrity": "sha512-W34UD/ej+epjRMxBahnsmPjMFzPNx7maNKZMsIU9jbuuHcFeamM/BmqCZGNClFiFKu5nNE0MKvl7D+1QVFkd1g==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1036.0.tgz", + "integrity": "sha512-4+Q0nEoAsKD2A5LktgJBH4eBLJ8cS2FNKy4YpYPYC+y+kHHszGp82dkSYPI2pogXHj1+hFZ41WziQcMz9+SBBg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -1417,48 +1497,48 @@ } }, "node_modules/@aws-sdk/client-ecs": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecs/-/client-ecs-3.1031.0.tgz", - "integrity": "sha512-mWocDnYKsHtLS2dL2J7+oI/zkqxdSPPico/CdD8r2q865iB0i8F8f7EdiB6Wrg4PRP+Ajr/LDaAgOqq4ziXleA==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecs/-/client-ecs-3.1036.0.tgz", + "integrity": "sha512-Yfw5xf0kcX80xI8Zg4/0xWG+WSFSJ+z752TZ4UqjyoTQrp+DwBXigbyegSC68XsNlz+YIJYNKcuXxxmJrfdjig==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -1468,48 +1548,48 @@ } }, "node_modules/@aws-sdk/client-elastic-load-balancing-v2": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-elastic-load-balancing-v2/-/client-elastic-load-balancing-v2-3.1031.0.tgz", - "integrity": "sha512-uNjewALsxsQuvuPTcivzEWHti0srxPYobgHVhNPT7/ARqrprqWbPkrLfDDwDxtouiGUlza+hEecPme8Y8723Ng==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-elastic-load-balancing-v2/-/client-elastic-load-balancing-v2-3.1036.0.tgz", + "integrity": "sha512-nwEuh1hijoIAYhLPWcFi3ltidlN8EayqtrCkXbP3ggzszkHV0g1fO933f3Sr67Uuo9DlYdhdyLubECbAeTIE+w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -1519,48 +1599,48 @@ } }, "node_modules/@aws-sdk/client-iam": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.1031.0.tgz", - "integrity": "sha512-DeU8ajiEA4rPqFsXB2D2l8+ydqrgpogaARX2Tgi1KrsnD+I8M/11ErtDdDNClr7ARcGC0kCdP5I54tO5B/pPzQ==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.1036.0.tgz", + "integrity": "sha512-cVe1Cg9NnqirhJiJNeJ1K7PFQ7QLKnhcbdZDOa/+nPTZRW9LhAEgDyuNTRFKkLfKtkEOwcqGozmdi2Bk7eaLCA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -1570,48 +1650,48 @@ } }, "node_modules/@aws-sdk/client-kms": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-kms/-/client-kms-3.1031.0.tgz", - "integrity": "sha512-A1YNU+vrnZcmjI1ZvnNwjlMG1uX14FLmG+EqHYZgIhlYJjfS4aMFdLs+jxzkkjEqRaX9VCgXXJZNb5fobrA/4Q==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-kms/-/client-kms-3.1036.0.tgz", + "integrity": "sha512-tpqED4Wxmwx3gKv4czaMBbptyoYYX/2KuJ/F3+ZUQ5xPimQ448Wrx6Ci0aOfnbBIKek8DIL24LdgYSoApgnHoQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1620,26 +1700,26 @@ } }, "node_modules/@aws-sdk/client-lambda": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1031.0.tgz", - "integrity": "sha512-ri3DCr1QvTBprrCxvcDGaMbh3YJHUuUUQpA6pCq9PbrFgmyBpznR2xzI1X1vNxB4Nu9D3bqPYzUxa+agI8+svQ==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1036.0.tgz", + "integrity": "sha512-1JwkI5NXsYrwyEhtBWb441c87DJAn+JFa1/7i1xtizuWX1ibEq9jQBGiz+eQfUZNkVMRnpIGiGDOETJobj+i9w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", @@ -1647,25 +1727,25 @@ "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", - "@smithy/util-stream": "^4.5.23", + "@smithy/util-retry": "^4.3.4", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -1675,48 +1755,48 @@ } }, "node_modules/@aws-sdk/client-resource-groups-tagging-api": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-resource-groups-tagging-api/-/client-resource-groups-tagging-api-3.1031.0.tgz", - "integrity": "sha512-wH1vJ3hiaVCgQgGWfSnj4ZH9Bcm7k3XO5Vn9QSCuEFjXlke0GzGnqh0Gpa/vZ5ICj/LSU+CvnLvuNs5PKfEPGQ==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-resource-groups-tagging-api/-/client-resource-groups-tagging-api-3.1037.0.tgz", + "integrity": "sha512-1+sv7vbSSRqVMBTvgFPFopXZ/SGdz3jP9aIHM8eOIuuZGYSmuqiXkNd7Ag5yxIQqEDO8sASevCqscEqN0f4OUw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1725,49 +1805,49 @@ } }, "node_modules/@aws-sdk/client-route-53": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-route-53/-/client-route-53-3.1031.0.tgz", - "integrity": "sha512-GgefEfcOnT8DylmOG2NEZLu0gDAqR/eg5EEl6AugmKudIxyThfuTv3oCQRrVyh6a/5GNzcQS3CkIgP/OhJ5wAA==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-route-53/-/client-route-53-3.1036.0.tgz", + "integrity": "sha512-xfIfxc6MV6sLa7nlNGdFQO/5f4KWWtnDtOJCrWa2ar7HBnQfz2WDG5vKh+MhuQL/ReUoPS0zQKWon7p9vEbDsA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-sdk-route53": "^3.972.12", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -1777,34 +1857,34 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1031.0.tgz", - "integrity": "sha512-HflwKSpDl2pwPiH116n9dy1pKW+5yEoiGFor6NO1yADgge+QY5LqluBiHscD7k/o9ib+dgxf6Vg4WsctcRT3OA==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1037.0.tgz", + "integrity": "sha512-DBmA1jAW8ST6C4srBxeL1/RLIir/d8WOm4s4mi59mGp6mBktHM59Kwb7GuURaCO60cotuce5zr0sKpMLPcBQyA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-bucket-endpoint": "^3.972.10", "@aws-sdk/middleware-expect-continue": "^3.972.10", - "@aws-sdk/middleware-flexible-checksums": "^3.974.8", + "@aws-sdk/middleware-flexible-checksums": "^3.974.13", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-location-constraint": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-sdk-s3": "^3.972.29", + "@aws-sdk/middleware-sdk-s3": "^3.972.34", "@aws-sdk/middleware-ssec": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", - "@aws-sdk/signature-v4-multi-region": "^3.996.17", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.22", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", @@ -1815,25 +1895,25 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/md5-js": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", - "@smithy/util-stream": "^4.5.23", + "@smithy/util-retry": "^4.3.4", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -1843,48 +1923,48 @@ } }, "node_modules/@aws-sdk/client-secrets-manager": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1031.0.tgz", - "integrity": "sha512-yfvYEg/A9OTL88vZ9yMPb9TUC8IcDBRXShy11HOSLHyhjEt6uZ5QHnLjzN7E475prcNI/362fHIPUxOFoucOpQ==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1036.0.tgz", + "integrity": "sha512-4Q0Rqh1CrNfwmAOrQF9FiuU2QmV51cTSa+rJJNan7/KzYUUlUFmavuWpKH+S3preT8iaTlCT0JyCqO46kg24RA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1893,48 +1973,48 @@ } }, "node_modules/@aws-sdk/client-sfn": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sfn/-/client-sfn-3.1031.0.tgz", - "integrity": "sha512-KA5FwaJQb4uulSIYYN3cg4s1jM3OF1PVvCvO5pwRPTvd1rngpsgcs6ISujwdgZ1JgO7pvR2vfhNBzKPJRlSCFg==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sfn/-/client-sfn-3.1036.0.tgz", + "integrity": "sha512-S03TS4nm3noq7tRW4FpUONLw7fihP/8WMmHk/xI4j1ywr91rJzl1aXMJZUfA9ttDqXc4fr6wz2MZ/L/UU5k+Sw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1943,48 +2023,48 @@ } }, "node_modules/@aws-sdk/client-ssm": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1031.0.tgz", - "integrity": "sha512-d05WMqhqEF45sNr99E1Zg4Cg0a1dPMKAvKAA0g7weGG4XwFLBDq7gN/D8THMcP62AVBhGqwUFkOVfGeLxgguQQ==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1036.0.tgz", + "integrity": "sha512-MGUcW/ITX37ktOQpBI9T2x0bHt1KFi5z8FjkSRC8FrBezxyViMMRbvJcbU3sOojsRvFpZePUgfB4RkcYu7BAbg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" @@ -1994,48 +2074,49 @@ } }, "node_modules/@aws-sdk/client-sts": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1031.0.tgz", - "integrity": "sha512-95PpEMU8EK4tOIWAVlPrJ2Ie1rb18vo+7Vf/Q233fgRkB4/yRLh5efPN+xQe15sTmrTNQzHcv2+xemsD76Vw+Q==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1037.0.tgz", + "integrity": "sha512-Ye+BEvy1Fd/JtqfF1T9PiodIU52/Cd9sP4oBLnj8QQEyYRUcYG1OQ2xIFXF/gzAAMjfVN8HqGJo9LxdmScxZAQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.22", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2044,48 +2125,48 @@ } }, "node_modules/@aws-sdk/client-xray": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-xray/-/client-xray-3.1031.0.tgz", - "integrity": "sha512-U6ZyVJMDeoWQINycESmN4Fdl/A4k1CQilJDaHE8hcZB5y2phx5a/V2ycQPK5q8ZsGb9tW5NphAldr1eGjXG1Lg==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-xray/-/client-xray-3.1037.0.tgz", + "integrity": "sha512-cnic610qpFrbR3gNw0pFi6EFrkDhDJOHlOnQzpdjBQ38O3QXwkON6Kco8IyTs7TlyOr7HRmHnBiEYVDZrLVC0w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2094,22 +2175,23 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.0.tgz", - "integrity": "sha512-8j+dMtyDqNXFmi09CBdz8TY6Ltf2jhfHuP6ZvG4zVjndRc6JF0aeBUbRwQLndbptFCsdctRQgdNWecy4TIfXAw==", + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.5.tgz", + "integrity": "sha512-lMPlYlYfQdNZhlkJgnkmESwrY+hNh3PljmZ+37oAqLNdJ6rnILAwFSyc6B3bJeDOtMORNnMQIej0aTRuOlDyhQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.18", - "@smithy/core": "^3.23.15", + "@aws-sdk/xml-builder": "^3.972.19", + "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2131,12 +2213,12 @@ } }, "node_modules/@aws-sdk/credential-provider-cognito-identity": { - "version": "3.972.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.23.tgz", - "integrity": "sha512-s348nPRtP3ROgG3CwOrG7RmJ6G9vYJMtHKQkesYLEBRG9Oo3TrjlYUZz03ejgt36f55NeOAQKidG8+GXo8/gsg==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.28.tgz", + "integrity": "sha512-UXhc4FfxbfNaIqycDnIZ+W8CMAoCtcJJfZkq+cWSUwQRN0V0d0uAoN2qCFyKZip8inlHeKJmNQsPliKKcElP8Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/nested-clients": "^3.996.20", + "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", @@ -2147,12 +2229,12 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.26.tgz", - "integrity": "sha512-WBHAMxyPdgeJY6ZGLvq9mJwzZ+GaNUROQbfdVshtMsDVBrZTj5ZuFjKclSjSHvKSHJ4Y4O2yvI/aA/hrJbYfng==", + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.31.tgz", + "integrity": "sha512-X/yGB73LmDW/6MdDJGCDzZBUXnM3ys4vs9l+5ZTJmiEswDdP1OjeoAFlFjVGS9o4KB2wZWQ9KOfdVNSSK6Ep3w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.0", + "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", @@ -2163,20 +2245,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.28.tgz", - "integrity": "sha512-+1DwCjjpo1WoiZTN08yGitI3nUwZUSQWVWFrW4C46HqZwACjcUQ7C66tnKPBTVxrEYYDOP11A6Afmu1L6ylt3g==", + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.33.tgz", + "integrity": "sha512-c0ZF+lwoWVvX5iCaGKL5T/4DnIw88CGqxA0BcBs3U86mIp5EZYPVg+KSPkMXOyokmADvNewiMUfSG2uFwjRp0g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.0", + "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.23", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -2184,19 +2266,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.30.tgz", - "integrity": "sha512-Fg1oJcoijwOZjTxdbx+ubqbQl8YEQ4Cwhjw6TWzQjuDEvQYNhnCXW2pN7eKtdTrdE4a6+5TVKGSm2I+i2BKIQg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-env": "^3.972.26", - "@aws-sdk/credential-provider-http": "^3.972.28", - "@aws-sdk/credential-provider-login": "^3.972.30", - "@aws-sdk/credential-provider-process": "^3.972.26", - "@aws-sdk/credential-provider-sso": "^3.972.30", - "@aws-sdk/credential-provider-web-identity": "^3.972.30", - "@aws-sdk/nested-clients": "^3.996.20", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.35.tgz", + "integrity": "sha512-jsU4u/cRkKFLKQS0k918FQ27fzXLG5ENiLWQMYE6581zLeI2hWh04ptlrvZMB3wJT/5d+vSzJk74X1CMFr4y8Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-env": "^3.972.31", + "@aws-sdk/credential-provider-http": "^3.972.33", + "@aws-sdk/credential-provider-login": "^3.972.35", + "@aws-sdk/credential-provider-process": "^3.972.31", + "@aws-sdk/credential-provider-sso": "^3.972.35", + "@aws-sdk/credential-provider-web-identity": "^3.972.35", + "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", @@ -2209,13 +2291,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.30.tgz", - "integrity": "sha512-nchIrrI/7dgjG1bW/DEWOJc00K9n+kkl6B8Mk0KO6d4GfWBOXlVr9uHp7CJR9FIrjmov5SGjHXG2q9XAtkRw6Q==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.35.tgz", + "integrity": "sha512-5oa3j0cA50jPqgNhZ9XdJVopuzUf1klRb28/2MfLYWWiPi9DRVvbrBWT+DidbHTT36520VuXZJahQwR+YgSjrg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/nested-clients": "^3.996.20", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", @@ -2228,17 +2310,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.31.tgz", - "integrity": "sha512-99OHVQ6eZ5DTxiOWgHdjBMvLqv7xoY4jLK6nZ1NcNSQbAnYZkQNIHi/VqInc9fnmg7of9si/z+waE6YL9OQIlw==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.36.tgz", + "integrity": "sha512-4nT2T8Z7vH8KE9EdjEsuIlHpZSlcaK2PrKbQBjuUGU46BCCzF3WvP0u0Uiosni3Ykmmn4rWLVawoOCLotUtCbg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.26", - "@aws-sdk/credential-provider-http": "^3.972.28", - "@aws-sdk/credential-provider-ini": "^3.972.30", - "@aws-sdk/credential-provider-process": "^3.972.26", - "@aws-sdk/credential-provider-sso": "^3.972.30", - "@aws-sdk/credential-provider-web-identity": "^3.972.30", + "@aws-sdk/credential-provider-env": "^3.972.31", + "@aws-sdk/credential-provider-http": "^3.972.33", + "@aws-sdk/credential-provider-ini": "^3.972.35", + "@aws-sdk/credential-provider-process": "^3.972.31", + "@aws-sdk/credential-provider-sso": "^3.972.35", + "@aws-sdk/credential-provider-web-identity": "^3.972.35", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", @@ -2251,12 +2333,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.26.tgz", - "integrity": "sha512-jibxNld3m+vbmQwn98hcQ+fLIVrx3cQuhZlSs1/hix48SjDS5/pjMLwpmtLD/lFnd6ve1AL4o1bZg3X1WRa2SQ==", + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.31.tgz", + "integrity": "sha512-eKeT4MXumpBJsrDLCYcSzIkFPVTFn/es7It2oogp2OhU/ic7P/+xzFpQx9ZhwtXS57Mc5S42BPWi7lHmvs/nYg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.0", + "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2268,14 +2350,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.30.tgz", - "integrity": "sha512-honYIM17F/+QSWJRE84T4u//ofqEi7rLbnwmIpu7fgFX5PML78wbtdSAy5Xwyve3TLpE9/f9zQx0aBVxSjAOPw==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.35.tgz", + "integrity": "sha512-bCuBdfnj0KGDMdLp6utMTLiJcFN2ek9EgZinxQZZSc3FxjJ/HSqeqab2cjbnoNfy8RM6suDCsRkmVY1izp9I+A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/nested-clients": "^3.996.20", - "@aws-sdk/token-providers": "3.1031.0", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/token-providers": "3.1036.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2287,13 +2369,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.30.tgz", - "integrity": "sha512-CyL4oWUlONQRN2SsYMVrA9Z3i3QfLWTQctI8tuKbjNGCVVDCnJf/yMbSJCOZgpPFRtxh7dgQwvpqwmJm+iytmw==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.35.tgz", + "integrity": "sha512-swW6Bwvl8lanyEMtZOWE/oR6yqcRQH4HTQZUVsnDVgoXvRjRywpYpLv2BWwjUFyjPrqsdX6FeTkf4tMSe/qFTQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/nested-clients": "^3.996.20", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2305,26 +2387,26 @@ } }, "node_modules/@aws-sdk/credential-providers": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1031.0.tgz", - "integrity": "sha512-SN11xsyj+iggyPpnfTbthZkcSPeX5aHQiAYMzTbOLYOcbhYLS3mDKQvon6bDBLRNOONkmuC/9sQWjuHt8A4f8g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-cognito-identity": "3.1031.0", - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/credential-provider-cognito-identity": "^3.972.23", - "@aws-sdk/credential-provider-env": "^3.972.26", - "@aws-sdk/credential-provider-http": "^3.972.28", - "@aws-sdk/credential-provider-ini": "^3.972.30", - "@aws-sdk/credential-provider-login": "^3.972.30", - "@aws-sdk/credential-provider-node": "^3.972.31", - "@aws-sdk/credential-provider-process": "^3.972.26", - "@aws-sdk/credential-provider-sso": "^3.972.30", - "@aws-sdk/credential-provider-web-identity": "^3.972.30", - "@aws-sdk/nested-clients": "^3.996.20", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1037.0.tgz", + "integrity": "sha512-TPPoQzfNkWltNgjJn3RRY1S8VXffDvv49xGGs9K0DrYS9LZCLLsoHmSmShx9HQusPc/4Oz23rfRWTolCU19PdQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.1037.0", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.28", + "@aws-sdk/credential-provider-env": "^3.972.31", + "@aws-sdk/credential-provider-http": "^3.972.33", + "@aws-sdk/credential-provider-ini": "^3.972.35", + "@aws-sdk/credential-provider-login": "^3.972.35", + "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/credential-provider-process": "^3.972.31", + "@aws-sdk/credential-provider-sso": "^3.972.35", + "@aws-sdk/credential-provider-web-identity": "^3.972.35", + "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", @@ -2336,17 +2418,17 @@ } }, "node_modules/@aws-sdk/ec2-metadata-service": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/ec2-metadata-service/-/ec2-metadata-service-3.1031.0.tgz", - "integrity": "sha512-8XbwYFfk+4FqqxO7Y5YRBEoZxcArTZSoOe+lyH62psDuGUWu2Akb6W0zOB9f/zvnA4IHSESTx6k8QWi9cQrtwg==", + "version": "3.1018.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/ec2-metadata-service/-/ec2-metadata-service-3.1018.0.tgz", + "integrity": "sha512-mb3RlD9JoTyhTYutFSscmsGKbNqYxtJUEabgOZMw7NJByXMRId72ogpZIRHC0ChFL5R+ev4zMyChYwDdxFxCQA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", - "@smithy/protocol-http": "^5.3.14", - "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.23", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" }, "engines": { @@ -2369,15 +2451,15 @@ } }, "node_modules/@aws-sdk/lib-storage": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1031.0.tgz", - "integrity": "sha512-QC6MwdeXWZt9wnOV6/rgxSnVUcDeoHr5TCnCm0UPuSQOwuLX1/2QJa6oo7PdfdHg+i3G+VOnmG8QZ3tYxvBGRA==", + "version": "3.1018.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1018.0.tgz", + "integrity": "sha512-bAFWDZUktLleORG0CXtYkfzMcbIOsJXeGK6Pkq7XLbFjE0QQGze8Y0mH8x0R7ogMrWU43oGmUDG3cs0UsthTOw==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", - "@smithy/types": "^4.14.1", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", @@ -2387,7 +2469,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@aws-sdk/client-s3": "^3.1031.0" + "@aws-sdk/client-s3": "^3.1018.0" } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { @@ -2439,15 +2521,15 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.8.tgz", - "integrity": "sha512-c+bD9J3f56oOPmmseCfT6PhkykiC5vtq0/ZDaK7U1Da/u/b7ZhhidfTHGnqa1pMCro9ZkM4QBcJ70lP7RgnPWg==", + "version": "3.974.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.13.tgz", + "integrity": "sha512-b6QUe2hQX9XsnCzp6mtzVaERhganDKeb8lmGL6pVhr7rRVH9S9keDFW7uKytuuqmcY5943FixoGqn/QL+sbUBA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.974.0", + "@aws-sdk/core": "^3.974.5", "@aws-sdk/crc64-nvme": "^3.972.7", "@aws-sdk/types": "^3.973.8", "@smithy/is-array-buffer": "^4.2.2", @@ -2455,7 +2537,7 @@ "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.23", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2523,17 +2605,17 @@ } }, "node_modules/@aws-sdk/middleware-sdk-ec2": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-ec2/-/middleware-sdk-ec2-3.972.20.tgz", - "integrity": "sha512-nmkwdX9ImAvVtzBl65GzttDUV6GUL3KNbDs0nk3XYQS6KdQnxuGnvirw1CTCsDrMJOQgMmKY3TEHc0r+zZGc0Q==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-ec2/-/middleware-sdk-ec2-3.972.22.tgz", + "integrity": "sha512-i9BeGH8OIPXmDuu5VZEvq3QVzP2Upt0QJsW/0ziS873CJ+zFiCyobiqQ3QTgJpxIsBBXBswsRQajEG+PvuKxYg==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-format-url": "^3.972.10", - "@smithy/middleware-endpoint": "^4.4.30", + "@smithy/middleware-endpoint": "^4.4.32", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -2556,23 +2638,23 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.29.tgz", - "integrity": "sha512-ayk68penP1WDZmyDZVeUQzq+HI3iDq5xezohUxIQoKFKE0KdCnDcxLCNnLanhBfgQDaKiGHVXhxZMDWJAEEBsQ==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.34.tgz", + "integrity": "sha512-/UL96JKjsjdodcRRMKl99tLQvK6Oi9ptLC9iU1yiTF/ruaDX0mtBBtnLNZDxIZRJOCVOtB49ed1YaTadqygk8Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.0", + "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.15", + "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.23", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2595,18 +2677,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.30.tgz", - "integrity": "sha512-lCz6JfelhjD6Eco1urXM2rOYRaxROSqeoY6IEKx+soegFJOajmIBCMHTAWuJl25Wf9IAST+i0/yOk9G3rMV26A==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.35.tgz", + "integrity": "sha512-hOFWNOjVmOocpRlrU04nYxjMOeoe0Obu5AXEuhB8zblMCPl3cG1hdluQCZERRKFyhMQjwZnDbhSHjoMUjetFGw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.0", + "@aws-sdk/core": "^3.974.5", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-endpoints": "^3.996.8", + "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "tslib": "^2.6.2" }, "engines": { @@ -2637,47 +2719,48 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.20.tgz", - "integrity": "sha512-bzPdsNQnCh6TvvUmTHLZlL8qgyME6mNiUErcRMyJPywIl1BEu2VZRShel3mUoSh89bOBEXEWtjocDMolFxd/9A==", + "version": "3.997.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.3.tgz", + "integrity": "sha512-SivE6GP228IVgfsrr2c/vqTg95X0Qj39Yw4uIrcddpkUzIltNMoNOR62leHOLhODfjv9K8X2mPTwS69A5kT0nQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.0", + "@aws-sdk/core": "^3.974.5", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.30", - "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.22", "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.16", - "@smithy/config-resolver": "^4.4.16", - "@smithy/core": "^3.23.15", + "@aws-sdk/util-user-agent-node": "^3.973.21", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.30", - "@smithy/middleware-retry": "^4.5.3", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.47", - "@smithy/util-defaults-mode-node": "^4.2.52", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2686,13 +2769,13 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.12.tgz", - "integrity": "sha512-QQI43Mxd53nBij0pm8HXC+t4IOC6gnhhZfzxE0OATQyO6QfPV4e+aTIRRuAJKA6Nig/cR8eLwPryqYTX9ZrjAQ==", + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", + "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@smithy/config-resolver": "^4.4.16", + "@smithy/config-resolver": "^4.4.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -2702,12 +2785,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.17.tgz", - "integrity": "sha512-qDwhXw+SIM5vMAMgflA8LPRa7xP+/wgXYr++llzCOwp7kkM2v7GGGWzoRW8e1CACaO4ljZd/NSQbsRLKm1sMWw==", + "version": "3.996.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.22.tgz", + "integrity": "sha512-/rXhMXteD+BqhFd0nYprAgcZ/KtU+963uftPqd3tiFcFfooHZINXUGtOmo2SQjRVauCTNqIEzkwuSETdZFqTTA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.29", + "@aws-sdk/middleware-sdk-s3": "^3.972.34", "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", @@ -2719,13 +2802,13 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1031.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1031.0.tgz", - "integrity": "sha512-zj/PvnbQK/2KJNln5K2QRI9HSsy+B4emz2gbQyUHkk6l7Lidu83P/9tfmC2cJXkcC3vdmyKH2DP3Iw/FDfKQuQ==", + "version": "3.1036.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1036.0.tgz", + "integrity": "sha512-aNSJ6jjDYayxN9ZA1JpycVScX93Lx03kKZ1EXt3DGOTahcWVLJj3oLAlop0xKP+vP2Ga2t49p1tEaMkTbCCaZA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.0", - "@aws-sdk/nested-clients": "^3.996.20", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2762,15 +2845,15 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.7.tgz", - "integrity": "sha512-ty4LQxN1QC+YhUP28NfEgZDEGXkyqOQy+BDriBozqHsrYO4JMgiPhfizqOGF7P+euBTZ5Ez6SKlLAMCLo8tzmw==", + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -2817,12 +2900,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.16.tgz", - "integrity": "sha512-ccvu0FNCI0C6OqmxI/tWn7BD8qGooWuURssiIM+6vbksFO8opXR4JOGtGYPj8QYzN/vfwNYrcK344PPbYuvzRg==", + "version": "3.973.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.21.tgz", + "integrity": "sha512-Av4UHTcAWgdvbN0IP9pbtf4Qa1+6LtJqQdZWj5pLn5J67w0pnJJAZZ+7JPPcj2KN3378zD2JDM9DwJKEyvyMTQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.30", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", @@ -3205,9 +3288,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -3222,9 +3305,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -3239,9 +3322,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -3256,9 +3339,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -3273,9 +3356,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -3290,9 +3373,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -3307,9 +3390,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -3324,9 +3407,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -3341,9 +3424,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -3358,9 +3441,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -3375,9 +3458,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -3392,9 +3475,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -3409,9 +3492,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -3426,9 +3509,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -3443,9 +3526,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -3460,9 +3543,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -3477,9 +3560,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -3494,9 +3577,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -3511,9 +3594,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -3528,9 +3611,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -3545,9 +3628,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -3562,9 +3645,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -3579,9 +3662,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -3596,9 +3679,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -3613,9 +3696,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -3630,9 +3713,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -3800,9 +3883,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", "dev": true, "license": "MIT", "engines": { @@ -4016,16 +4099,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@nodelib/fs.scandir": { @@ -4085,9 +4174,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", - "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", + "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4099,19 +4188,17 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.213.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz", - "integrity": "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==", + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.214.0.tgz", + "integrity": "sha512-Tx/59RmjBgkXJ3qnsD04rpDrVWL53LU/czpgLJh+Ab98nAroe91I7vZ3uGN9mxwPS0jsZEnmqmHygVwB2vRMlA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.213.0", - "@opentelemetry/core": "2.6.0", - "@opentelemetry/resources": "2.6.0", - "@opentelemetry/sdk-logs": "0.213.0", - "@opentelemetry/sdk-metrics": "2.6.0", - "@opentelemetry/sdk-trace-base": "2.6.0", - "protobufjs": "^7.0.0" + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-metrics": "2.6.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4120,31 +4207,48 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/resources": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", - "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.0", - "@opentelemetry/semantic-conventions": "^1.29.0" + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz", + "integrity": "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-logs": "0.214.0", + "@opentelemetry/sdk-metrics": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1", + "protobufjs": "^7.0.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.213.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz", - "integrity": "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==", + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", + "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.213.0", - "@opentelemetry/core": "2.6.0", - "@opentelemetry/resources": "2.6.0", + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4154,52 +4258,317 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz", - "integrity": "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==", + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", + "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.0", - "@opentelemetry/resources": "2.6.0" + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", - "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.214.0.tgz", + "integrity": "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.0", - "@opentelemetry/resources": "2.6.0", - "@opentelemetry/semantic-conventions": "^1.29.0" + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-transformer": "0.214.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, "engines": { - "node": ">=14" + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz", + "integrity": "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-logs": "0.214.0", + "@opentelemetry/sdk-metrics": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1", + "protobufjs": "^7.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", + "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", + "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz", + "integrity": "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-logs": "0.213.0", + "@opentelemetry/sdk-metrics": "2.6.0", + "@opentelemetry/sdk-trace-base": "2.6.0", + "protobufjs": "^7.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", + "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz", + "integrity": "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz", + "integrity": "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", + "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", + "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", + "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", + "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", + "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" } }, "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "dev": true, "license": "MIT", "funding": { @@ -4228,6 +4597,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -4293,9 +4678,9 @@ "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", "cpu": [ "arm64" ], @@ -4310,9 +4695,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", "cpu": [ "arm64" ], @@ -4327,9 +4712,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", "cpu": [ "x64" ], @@ -4344,9 +4729,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", "cpu": [ "x64" ], @@ -4361,9 +4746,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", - "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", "cpu": [ "arm" ], @@ -4378,9 +4763,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", "cpu": [ "arm64" ], @@ -4395,9 +4780,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", "cpu": [ "arm64" ], @@ -4412,9 +4797,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", "cpu": [ "ppc64" ], @@ -4429,9 +4814,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", "cpu": [ "s390x" ], @@ -4446,9 +4831,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "cpu": [ "x64" ], @@ -4463,9 +4848,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", "cpu": [ "x64" ], @@ -4480,9 +4865,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", "cpu": [ "arm64" ], @@ -4497,9 +4882,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", - "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "cpu": [ "wasm32" ], @@ -4507,80 +4892,38 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.3" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", - "cpu": [ - "x64" - ], + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, @@ -4592,9 +4935,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", - "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", "dev": true, "license": "MIT" }, @@ -4605,61 +4948,61 @@ "license": "MIT" }, "node_modules/@secretlint/config-creator": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-11.7.1.tgz", - "integrity": "sha512-ET4EXYzSjl1EgwZRJmU/2NjQVIW4u+2nIZu4hs6Og5ys2MbSEheHNoBXOifQOZDgObleP8S9aU3BV2bUqPEQxw==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-12.2.0.tgz", + "integrity": "sha512-enoydCMrJ8rmrM09qxDBd2XU1V3u9N9CfjRyUbYh3+m74G17u2PCTnlAw5UyeobewCb06d4Dym5t5ybCabATyA==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "11.7.1" + "@secretlint/types": "12.2.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@secretlint/config-loader": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-11.7.1.tgz", - "integrity": "sha512-XExWOV82/nbuo9N+m6G9by4n2mnts3Z1k2i499pIJ0+VaASbA06u1fQFErMGYrobeUpSTC5i077Ee7G2lJhFiw==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-12.2.0.tgz", + "integrity": "sha512-f7B9o6YF1jhTtd0ccJywcliCWkP02eYNM4efmua77AuztQTkXLVsw6eECXGAfZ9vh9uPHAK87Km6X4ta5hhtlA==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/profiler": "11.7.1", - "@secretlint/resolver": "11.7.1", - "@secretlint/types": "11.7.1", + "@secretlint/profiler": "12.2.0", + "@secretlint/resolver": "12.2.0", + "@secretlint/types": "12.2.0", "ajv": "^8.18.0", "debug": "^4.4.3", "rc-config-loader": "^4.1.4" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@secretlint/core": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-11.7.1.tgz", - "integrity": "sha512-D1a/6U3JT2hhs+OpocAf1j+2t79ExrFqNm8j7pilhCQqpJ9QIEexGs1ty3v9wDACm48v0PFhCNCyn4hyctGYig==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-12.2.0.tgz", + "integrity": "sha512-ZT4irO8fPUg2810kcnfNQZ+AHIIYLFKyEqR91aSDl3g/RFOOLC66CAzGmMA1OuMc+sx9XE9TnM/IpLmLVvUSnA==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/profiler": "11.7.1", - "@secretlint/types": "11.7.1", + "@secretlint/profiler": "12.2.0", + "@secretlint/types": "12.2.0", "debug": "^4.4.3", "structured-source": "^4.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@secretlint/formatter": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-11.7.1.tgz", - "integrity": "sha512-x/9yd8DzUOgJlURhXH9Z0aIOmCn1XNvHxQPhlufPKFg9MWeDTysGGqUwEn1TBIlOvkeB38y2nJkrCkK/u2y7dg==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-12.2.0.tgz", + "integrity": "sha512-1KDSx4NgKObi8OQoPjBaGa41/sv9ZIrEMa94kQ3PhhVTPONP4N618W2c1CBVMuSNvRHilsKjXWZlKJKcIF4FlQ==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/resolver": "11.7.1", - "@secretlint/types": "11.7.1", + "@secretlint/resolver": "12.2.0", + "@secretlint/types": "12.2.0", "@textlint/linter-formatter": "^15.5.4", "@textlint/module-interop": "^15.5.4", "@textlint/types": "^15.5.4", @@ -4668,10 +5011,10 @@ "pluralize": "^8.0.0", "strip-ansi": "^7.2.0", "table": "^6.9.0", - "terminal-link": "^4.0.0" + "terminal-link": "^5.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@secretlint/formatter/node_modules/chalk": { @@ -4687,112 +5030,78 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@secretlint/formatter/node_modules/supports-hyperlinks": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", - "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">=14.18" - }, - "funding": { - "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" - } - }, - "node_modules/@secretlint/formatter/node_modules/terminal-link": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", - "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "supports-hyperlinks": "^3.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@secretlint/node": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-11.7.1.tgz", - "integrity": "sha512-spzkxcE+MjgO6cGir2AoLHyuCmOBufJLq/rHwUDmi/AOCPITGbCRvow+cBC5O9uWQcGOxDq/DgIsbgIlnooTVA==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-12.2.0.tgz", + "integrity": "sha512-7hZxi49l2pkGjCT/BQf+ElKqFcbxooH9JslCThRfAMElyL3KGo14HhGfFFyWhhTLH2enAds1nKXczhWBI3otIQ==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-loader": "11.7.1", - "@secretlint/core": "11.7.1", - "@secretlint/formatter": "11.7.1", - "@secretlint/profiler": "11.7.1", - "@secretlint/source-creator": "11.7.1", - "@secretlint/types": "11.7.1", + "@secretlint/config-loader": "12.2.0", + "@secretlint/core": "12.2.0", + "@secretlint/formatter": "12.2.0", + "@secretlint/profiler": "12.2.0", + "@secretlint/source-creator": "12.2.0", + "@secretlint/types": "12.2.0", "debug": "^4.4.3", "p-map": "^7.0.4" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@secretlint/profiler": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-11.7.1.tgz", - "integrity": "sha512-Fa1fCVT4izv8PRXL8t4KB6FwBEL2OYqOYtNsyssWd7giNoNcohCoLGnDhJNLcTxTyTqkDzLlGV0M4CYsCkcfZQ==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-12.2.0.tgz", + "integrity": "sha512-tB1NhUbCWH+32wSx6xE+Uj7nTUkidYEyW6B6pdGxsiZSM4SGz+FuKpr9OcylGsEphkkz1cQA3P9CjwCHcQqrnw==", "dev": true, "license": "MIT" }, "node_modules/@secretlint/resolver": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-11.7.1.tgz", - "integrity": "sha512-TShFKvFDqJalqM4r/NHrpdbd512odIOLZit01r65Szs5vdRq/W38B1QF0hwh0ZJq9wLOOpkTuVWkff0S4LFwZQ==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-12.2.0.tgz", + "integrity": "sha512-k3Mq4zeLpJtvBoEggEYstWhEiD23tL8qHbz/eYN+yQaQ2tItebIMd34qFX1jjeooiZdp/OuNWZA5JeyTw+SXcQ==", "dev": true, "license": "MIT" }, "node_modules/@secretlint/secretlint-rule-preset-recommend": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-11.7.1.tgz", - "integrity": "sha512-LsiEhDWoXi9GNx1Zp5eYRNajeUvBrZ82h3k/vzFqo0WoSdmpyDnkkzBTLXqWqj1VNyPCcTwlxOqnEmlz6lXc7A==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-12.2.0.tgz", + "integrity": "sha512-n4qknL6vYRelmyrAyV/Z8I85c6jS6yF/ZxpgcqebjJuECIiel8OT2wIVIq9vk0MwlQN35skaQu0KvfM8uuGeyA==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@secretlint/source-creator": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-11.7.1.tgz", - "integrity": "sha512-y7SS9VXbUJgVn0qT1KvaDsB0asLa+vsg55W6WLX4DStgkB1IrnUdwAtelZizDjeToLtjRhRRroCs8y6FFIpf3g==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-12.2.0.tgz", + "integrity": "sha512-FYPtOmnm5daQnY4m2mgf/06bXkCL2oj1CIs+76tBu80kE1RDH0/ejsVKsiw6O5H3E2J1ruchRpSXTAlyQw1rYA==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "11.7.1", + "@secretlint/types": "12.2.0", "istextorbinary": "^9.5.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@secretlint/types": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-11.7.1.tgz", - "integrity": "sha512-bIkBwIdQScZX5xm3DsLp3oL5U/KKUYWgmst39dZsDi55X9tKuPd7/uVpO1PqNrDW1I0QD5t6es/2LonG5RjxXg==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-12.2.0.tgz", + "integrity": "sha512-pIqhdWTFMN/cBfpZkAX1A8dqavsFvAdLobbxyMUHBUn/sUgXzyvUp7I52iyTr21EPc/BvOT9lDWdJBkcNz+n7Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "dev": true, "license": "MIT", "engines": { @@ -4828,15 +5137,15 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.16", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.16.tgz", - "integrity": "sha512-GFlGPNLZKrGfqWpqVb31z7hvYCA9ZscfX1buYnvvMGcRYsQQnhH+4uN6mWWflcD5jB4OXP/LBrdpukEdjl41tg==", + "version": "4.4.17", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", + "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, @@ -4845,9 +5154,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.15", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.15.tgz", - "integrity": "sha512-E7GVCgsQttzfujEZb6Qep005wWf4xiL4x06apFEtzQMWYBPggZh/0cnOxPficw5cuK/YjjkehKoIN4YUaSh0UQ==", + "version": "3.23.17", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", + "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.14", @@ -4856,7 +5165,7 @@ "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.23", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -5065,13 +5374,13 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.30", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.30.tgz", - "integrity": "sha512-qS2XqhKeXmdZ4nEQ4cOxIczSP/Y91wPAHYuRwmWDCh975B7/57uxsm5d6sisnUThn2u2FwzMdJNM7AbO1YPsPg==", + "version": "4.4.32", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz", + "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.15", - "@smithy/middleware-serde": "^4.2.18", + "@smithy/core": "^3.23.17", + "@smithy/middleware-serde": "^4.2.20", "@smithy/node-config-provider": "^4.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", @@ -5084,19 +5393,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.3.tgz", - "integrity": "sha512-TE8dJNi6JuxzGSxMCVd3i9IEWDndCl3bmluLsBNDWok8olgj65OfkndMhl9SZ7m14c+C5SQn/PcUmrDl57rSFw==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.5.tgz", + "integrity": "sha512-wnYOpB5vATFKWrY2Z9Alb0KhjZI6AbzU6Fbz3Hq2GnURdRYWB4q+qWivQtSTwXcmWUA3MZ6krfwL6Cq5MAbxsA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.15", + "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", - "@smithy/service-error-classification": "^4.2.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/service-error-classification": "^4.3.0", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.2", + "@smithy/util-retry": "^4.3.4", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -5105,12 +5414,12 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.18", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.18.tgz", - "integrity": "sha512-M6CSgnp3v4tYz9ynj2JHbA60woBZcGqEwNjTKjBsNHPV26R1ZX52+0wW8WsZU18q45jD0tw2wL22S17Ze9LpEw==", + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz", + "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.15", + "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" @@ -5148,9 +5457,9 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.3.tgz", - "integrity": "sha512-lc5jFL++x17sPhIwMWJ3YOnqmSjw/2Po6VLDlUIXvxVWRuJwRXnJ4jOBBLB0cfI5BB5ehIl02Fxr1PDvk/kxDw==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz", + "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==", "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.14", @@ -5216,9 +5525,9 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.14.tgz", - "integrity": "sha512-vVimoUnGxlx4eLLQbZImdOZFOe+Zh+5ACntv8VxZuGP72LdWu5GV3oEmCahSEReBgRJoWjypFkrehSj7BWx1HQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.0.tgz", + "integrity": "sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.1" @@ -5260,17 +5569,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.11", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.11.tgz", - "integrity": "sha512-wzz/Wa1CH/Tlhxh0s4DQPEcXSxSVfJ59AZcUh9Gu0c6JTlKuwGf4o/3P2TExv0VbtPFt8odIBG+eQGK2+vTECg==", + "version": "4.12.13", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.13.tgz", + "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.15", - "@smithy/middleware-endpoint": "^4.4.30", + "@smithy/core": "^3.23.17", + "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-stack": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", - "@smithy/util-stream": "^4.5.23", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -5367,13 +5676,13 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.47", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.47.tgz", - "integrity": "sha512-zlIuXai3/SHjQUQ8y3g/woLvrH573SK2wNjcDaHu5e9VOcC0JwM1MI0Sq0GZJyN3BwSUneIhpjZ18nsiz5AtQw==", + "version": "4.3.49", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz", + "integrity": "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==", "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -5382,16 +5691,16 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.52", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.52.tgz", - "integrity": "sha512-cQBz8g68Vnw1W2meXlkb3D/hXJU+Taiyj9P8qLJtjREEV9/Td65xi4A/H1sRQ8EIgX5qbZbvdYPKygKLholZ3w==", + "version": "4.2.54", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz", + "integrity": "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.16", + "@smithy/config-resolver": "^4.4.17", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", - "@smithy/smithy-client": "^4.12.11", + "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -5400,9 +5709,9 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.1.tgz", - "integrity": "sha512-wMxNDZJrgS5mQV9oxCs4TWl5767VMgOfqfZ3JHyCkMtGC2ykW9iPqMvFur695Otcc5yxLG8OKO/80tsQBxrhXg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz", + "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.14", @@ -5439,12 +5748,12 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.2.tgz", - "integrity": "sha512-2+KTsJEwTi63NUv4uR9IQ+IFT1yu6Rf6JuoBK2WKaaJ/TRvOiOVGcXAsEqX/TQN2thR9yII21kPUJq1UV/WI2A==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.4.tgz", + "integrity": "sha512-FY1UQQ1VFmMwiYp1GVS4MeaGD5O0blLNYK0xCRHU+mJgeoH/hSY8Ld8sJWKQ6uznkh14HveRGQJncgPyNl9J+A==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.14", + "@smithy/service-error-classification": "^4.3.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -5453,13 +5762,13 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.23", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.23.tgz", - "integrity": "sha512-N6on1+ngJ3RznZOnDWNveIwnTSlqxNnXuNAh7ez889ZZaRdXoNRTXKgmYOLe6dB0gCmAVtuRScE1hymQFl4hpg==", + "version": "4.5.25", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.25.tgz", + "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", "license": "Apache-2.0", "dependencies": { "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/node-http-handler": "^4.5.3", + "@smithy/node-http-handler": "^4.6.1", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", @@ -5750,12 +6059,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/normalize-package-data": { @@ -5781,20 +6090,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", - "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/type-utils": "8.58.2", - "@typescript-eslint/utils": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5804,22 +6113,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.2", + "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", - "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "engines": { @@ -5831,17 +6140,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", - "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.2", - "@typescript-eslint/types": "^8.58.2", + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", "debug": "^4.4.3" }, "engines": { @@ -5852,17 +6161,17 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", - "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5873,9 +6182,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", - "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5885,21 +6194,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", - "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5910,13 +6219,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", - "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5927,20 +6236,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", - "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.2", - "@typescript-eslint/tsconfig-utils": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5950,19 +6259,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", - "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2" + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5973,16 +6282,16 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", - "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/types": "8.57.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -6238,6 +6547,19 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", @@ -6281,14 +6603,14 @@ ] }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", - "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.5", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -6302,8 +6624,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.4", - "vitest": "4.1.4" + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6312,16 +6634,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -6330,13 +6652,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.4", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -6357,9 +6679,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { @@ -6370,13 +6692,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -6384,14 +6706,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -6400,9 +6722,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { @@ -6410,13 +6732,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", + "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -7309,9 +7631,9 @@ } }, "node_modules/bare-fs": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", - "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.6.tgz", + "integrity": "sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==", "license": "Apache-2.0", "dependencies": { "bare-events": "^2.5.4", @@ -7333,9 +7655,9 @@ } }, "node_modules/bare-os": { - "version": "3.8.7", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", - "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz", + "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==", "license": "Apache-2.0", "engines": { "bare": ">=1.14.0" @@ -7351,9 +7673,9 @@ } }, "node_modules/bare-stream": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", - "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.11.0.tgz", + "integrity": "sha512-Y/+iQ49fL3rIn6w/AVxI/2+BRrpmzJvdWt5Jv8Za6Ngqc6V227c+pYjYYgLdpR3MwQ9ObVXD0ZrqoBztakM0rw==", "license": "Apache-2.0", "dependencies": { "streamx": "^2.25.0", @@ -7406,9 +7728,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", - "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "version": "2.10.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", + "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -7496,9 +7818,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -7515,11 +7837,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -7558,14 +7880,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" }, "engines": { @@ -7614,9 +7936,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001788", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", - "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", "funding": [ { "type": "opencollective", @@ -7881,9 +8203,9 @@ "license": "Apache-2.0" }, "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "dev": true, "license": "MIT", "engines": { @@ -8193,9 +8515,9 @@ } }, "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz", + "integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -8249,9 +8571,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.340", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", - "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "version": "1.5.325", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", + "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -8283,9 +8605,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", - "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", @@ -8369,15 +8691,15 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", - "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.9", + "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.24.2", + "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", @@ -8389,7 +8711,8 @@ "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", - "math-intrinsics": "^1.1.0" + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" }, "engines": { "node": ">= 0.4" @@ -8469,9 +8792,9 @@ ] }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -8482,32 +8805,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escalade": { @@ -8639,14 +8962,14 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", - "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.16.1", - "resolve": "^2.0.0-next.6" + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -8833,9 +9156,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.0.tgz", - "integrity": "sha512-LDicyhrRFrIaheDYryeM2W8gWyZXnAs4zIr2WVPiOSeTmIu2RjR4x/9N0xLaRWZ+9hssBDGo3AadcohuzAvSvg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "license": "MIT", "dependencies": { "@babel/core": "^7.24.4", @@ -8848,7 +9171,7 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "node_modules/eslint-plugin-react-refresh": { @@ -8861,6 +9184,29 @@ "eslint": "^9 || ^10" } }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -9186,9 +9532,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "dev": true, "license": "MIT", "dependencies": { @@ -9635,9 +9981,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -9709,21 +10055,21 @@ } }, "node_modules/globby": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", - "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", + "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", "dev": true, "license": "MIT", "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", + "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" + "unicorn-magic": "^0.4.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -9921,24 +10267,27 @@ } }, "node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", "dev": true, "license": "ISC", "dependencies": { - "lru-cache": "^10.0.1" + "lru-cache": "^11.1.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/html-escaper": { "version": "2.0.2", @@ -10639,6 +10988,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -10992,9 +11354,9 @@ } }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -12837,24 +13199,24 @@ } }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "hosted-git-info": "^7.0.0", + "hosted-git-info": "^9.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { @@ -13279,9 +13641,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", "dev": true, "license": "MIT", "funding": { @@ -13289,19 +13651,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/path-type": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -13337,6 +13686,53 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -13395,9 +13791,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -13505,9 +13901,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -13649,55 +14045,29 @@ } }, "node_modules/read-pkg": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", - "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", "dev": true, "license": "MIT", "dependencies": { - "@types/normalize-package-data": "^2.4.3", - "normalize-package-data": "^6.0.0", - "parse-json": "^8.0.0", - "type-fest": "^4.6.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg/node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -13884,15 +14254,12 @@ } }, "node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", "is-core-module": "^2.16.1", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -13965,14 +14332,14 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", - "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.124.0", - "@rolldown/pluginutils": "1.0.0-rc.15" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" @@ -13981,21 +14348,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-x64": "1.0.0-rc.15", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, "node_modules/router": { @@ -14134,26 +14501,26 @@ "license": "MIT" }, "node_modules/secretlint": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-11.7.1.tgz", - "integrity": "sha512-igo+3xtwOisz3Ge0V1t6SzCnOLXSHjRIODZpqppUmfkb0EE0EbsEMdoUuunaacxffq3NTYSzpZcjbfrPYuO+qA==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-12.2.0.tgz", + "integrity": "sha512-nIl6JNhywewJIJGHNeCpu0/NXs4zyhTriz9683SWNIjH6etDyN/Q/L2fJ4nCxqdl7iZM3MlVtQQMtPDomQINuw==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-creator": "11.7.1", - "@secretlint/formatter": "11.7.1", - "@secretlint/node": "11.7.1", - "@secretlint/profiler": "11.7.1", - "@secretlint/resolver": "11.7.1", + "@secretlint/config-creator": "12.2.0", + "@secretlint/formatter": "12.2.0", + "@secretlint/node": "12.2.0", + "@secretlint/profiler": "12.2.0", + "@secretlint/resolver": "12.2.0", "debug": "^4.4.3", - "globby": "^14.1.0", - "read-pkg": "^9.0.1" + "globby": "^16.2.0", + "read-pkg": "^10.1.0" }, "bin": { "secretlint": "bin/secretlint.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/semver": { @@ -14309,13 +14676,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" + "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" @@ -14544,9 +14911,9 @@ } }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -15151,146 +15518,630 @@ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "license": "MIT" + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": ">=18" } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=8.0" + "node": ">=18" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=0.6" + "node": ">=18" } }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" + "node": ">=18" } }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, "bin": { - "tsx": "dist/cli.mjs" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" }, "optionalDependencies": { - "fsevents": "~2.3.3" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/type-check": { @@ -15306,9 +16157,9 @@ } }, "node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -15423,16 +16274,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", - "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.2", - "@typescript-eslint/parser": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2" + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -15443,7 +16294,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/uglify-js": { @@ -15478,19 +16329,19 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -15758,17 +16609,17 @@ } }, "node_modules/vite": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", - "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.15", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -15836,19 +16687,19 @@ } }, "node_modules/vitest": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", - "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.4", - "@vitest/mocker": "4.1.4", - "@vitest/pretty-format": "4.1.4", - "@vitest/runner": "4.1.4", - "@vitest/snapshot": "4.1.4", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -15876,12 +16727,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.4", - "@vitest/browser-preview": "4.1.4", - "@vitest/browser-webdriverio": "4.1.4", - "@vitest/coverage-istanbul": "4.1.4", - "@vitest/coverage-v8": "4.1.4", - "@vitest/ui": "4.1.4", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -16256,13 +17107,13 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "dev": true, "license": "ISC", "peerDependencies": { - "zod": "^3.25.28 || ^4" + "zod": "^3.25 || ^4" } }, "node_modules/zod-validation-error": { diff --git a/package.json b/package.json index 01a066d7e..e63de389e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aws/agentcore", - "version": "0.10.0", + "version": "0.11.0", "description": "CLI for Amazon Bedrock AgentCore", "license": "Apache-2.0", "repository": { @@ -69,6 +69,7 @@ "test:e2e": "vitest run --project e2e", "test:update-snapshots": "vitest run --project unit --update", "test:tui": "npm run build:harness && vitest run --project tui", + "test:browser": "npx playwright test --config browser-tests/playwright.config.ts", "bundle": "node scripts/bundle.mjs" }, "dependencies": { @@ -88,8 +89,11 @@ "@aws-sdk/credential-providers": "^3.893.0", "@aws/agent-inspector": "0.2.1", "@commander-js/extra-typings": "^14.0.0", - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", "@opentelemetry/otlp-transformer": "^0.213.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-metrics": "^2.6.1", "@smithy/shared-ini-file-loader": "^4.4.2", "commander": "^14.0.2", "dotenv": "^17.2.3", @@ -111,7 +115,8 @@ "@aws-sdk/client-cognito-identity-provider": "^3.1018.0", "@eslint/js": "^9.39.2", "@modelcontextprotocol/sdk": "^1.0.0", - "@secretlint/secretlint-rule-preset-recommend": "^11.3.0", + "@playwright/test": "^1.59.1", + "@secretlint/secretlint-rule-preset-recommend": "^12.2.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/js-yaml": "^4.0.9", "@types/node": "^25.0.3", @@ -122,7 +127,7 @@ "@xterm/headless": "^6.0.0", "aws-cdk-lib": "^2.248.0", "constructs": "^10.4.4", - "esbuild": "^0.27.2", + "esbuild": "^0.28.0", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -136,7 +141,7 @@ "lint-staged": "^16.2.7", "node-pty": "^1.1.0", "prettier": "^3.7.4", - "secretlint": "^11.3.0", + "secretlint": "^12.2.0", "tsx": "^4.21.0", "typescript": "^5", "typescript-eslint": "^8.50.1", diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index a81371d47..15877fa42 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -321,7 +321,7 @@ }, "eventExpiryDuration": { "type": "integer", - "minimum": 7, + "minimum": 3, "maximum": 365 }, "strategies": { diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index a1274e294..8fa17f318 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -1889,6 +1889,7 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/a2 exports[`Assets Directory Snapshots > Python framework assets > python/python/a2a/strands/capabilities/memory/session.py should match snapshot 1`] = ` "import os +import uuid from typing import Optional from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if memoryProviders.[0].strategies.length}}, RetrievalConfig{{/if}} @@ -1897,10 +1898,14 @@ from bedrock_agentcore.memory.integrations.strands.session_manager import AgentC MEMORY_ID = os.getenv("{{memoryProviders.[0].envVarName}}") REGION = os.getenv("AWS_REGION") -def get_memory_session_manager(session_id: str, actor_id: str) -> Optional[AgentCoreMemorySessionManager]: +def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Optional[AgentCoreMemorySessionManager]: if not MEMORY_ID: return None + # AgentCoreMemoryConfig rejects None; OAuth/CUSTOM_JWT callers can reach us + # without a runtime session header, so synthesize one when absent. + session_id = session_id or uuid.uuid4().hex + {{#if memoryProviders.[0].strategies.length}} retrieval_config = { {{#if (includes memoryProviders.[0].strategies "SEMANTIC")}} @@ -2724,6 +2729,7 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/ag exports[`Assets Directory Snapshots > Python framework assets > python/python/agui/strands/capabilities/memory/session.py should match snapshot 1`] = ` "import os +import uuid from typing import Optional from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if memoryProviders.[0].strategies.length}}, RetrievalConfig{{/if}} @@ -2732,10 +2738,14 @@ from bedrock_agentcore.memory.integrations.strands.session_manager import AgentC MEMORY_ID = os.getenv("{{memoryProviders.[0].envVarName}}") REGION = os.getenv("AWS_REGION") -def get_memory_session_manager(session_id: str, actor_id: str) -> Optional[AgentCoreMemorySessionManager]: +def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Optional[AgentCoreMemorySessionManager]: if not MEMORY_ID: return None + # AgentCoreMemoryConfig rejects None; OAuth/CUSTOM_JWT callers can reach us + # without a runtime session header, so synthesize one when absent. + session_id = session_id or uuid.uuid4().hex + {{#if memoryProviders.[0].strategies.length}} retrieval_config = { {{#if (includes memoryProviders.[0].strategies "SEMANTIC")}} @@ -4993,6 +5003,7 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/ht exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/capabilities/memory/session.py should match snapshot 1`] = ` "import os +import uuid from typing import Optional from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if memoryProviders.[0].strategies.length}}, RetrievalConfig{{/if}} @@ -5001,10 +5012,14 @@ from bedrock_agentcore.memory.integrations.strands.session_manager import AgentC MEMORY_ID = os.getenv("{{memoryProviders.[0].envVarName}}") REGION = os.getenv("AWS_REGION") -def get_memory_session_manager(session_id: str, actor_id: str) -> Optional[AgentCoreMemorySessionManager]: +def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Optional[AgentCoreMemorySessionManager]: if not MEMORY_ID: return None + # AgentCoreMemoryConfig rejects None; OAuth/CUSTOM_JWT callers can reach us + # without a runtime session header, so synthesize one when absent. + session_id = session_id or uuid.uuid4().hex + {{#if memoryProviders.[0].strategies.length}} retrieval_config = { {{#if (includes memoryProviders.[0].strategies "SEMANTIC")}} diff --git a/src/assets/python/a2a/strands/capabilities/memory/session.py b/src/assets/python/a2a/strands/capabilities/memory/session.py index 9661243b1..46883bf57 100644 --- a/src/assets/python/a2a/strands/capabilities/memory/session.py +++ b/src/assets/python/a2a/strands/capabilities/memory/session.py @@ -1,4 +1,5 @@ import os +import uuid from typing import Optional from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if memoryProviders.[0].strategies.length}}, RetrievalConfig{{/if}} @@ -7,10 +8,14 @@ MEMORY_ID = os.getenv("{{memoryProviders.[0].envVarName}}") REGION = os.getenv("AWS_REGION") -def get_memory_session_manager(session_id: str, actor_id: str) -> Optional[AgentCoreMemorySessionManager]: +def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Optional[AgentCoreMemorySessionManager]: if not MEMORY_ID: return None + # AgentCoreMemoryConfig rejects None; OAuth/CUSTOM_JWT callers can reach us + # without a runtime session header, so synthesize one when absent. + session_id = session_id or uuid.uuid4().hex + {{#if memoryProviders.[0].strategies.length}} retrieval_config = { {{#if (includes memoryProviders.[0].strategies "SEMANTIC")}} diff --git a/src/assets/python/agui/strands/capabilities/memory/session.py b/src/assets/python/agui/strands/capabilities/memory/session.py index 9661243b1..46883bf57 100644 --- a/src/assets/python/agui/strands/capabilities/memory/session.py +++ b/src/assets/python/agui/strands/capabilities/memory/session.py @@ -1,4 +1,5 @@ import os +import uuid from typing import Optional from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if memoryProviders.[0].strategies.length}}, RetrievalConfig{{/if}} @@ -7,10 +8,14 @@ MEMORY_ID = os.getenv("{{memoryProviders.[0].envVarName}}") REGION = os.getenv("AWS_REGION") -def get_memory_session_manager(session_id: str, actor_id: str) -> Optional[AgentCoreMemorySessionManager]: +def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Optional[AgentCoreMemorySessionManager]: if not MEMORY_ID: return None + # AgentCoreMemoryConfig rejects None; OAuth/CUSTOM_JWT callers can reach us + # without a runtime session header, so synthesize one when absent. + session_id = session_id or uuid.uuid4().hex + {{#if memoryProviders.[0].strategies.length}} retrieval_config = { {{#if (includes memoryProviders.[0].strategies "SEMANTIC")}} diff --git a/src/assets/python/http/strands/capabilities/memory/session.py b/src/assets/python/http/strands/capabilities/memory/session.py index dc0cb7bf5..159f82d19 100644 --- a/src/assets/python/http/strands/capabilities/memory/session.py +++ b/src/assets/python/http/strands/capabilities/memory/session.py @@ -1,4 +1,5 @@ import os +import uuid from typing import Optional from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if memoryProviders.[0].strategies.length}}, RetrievalConfig{{/if}} @@ -7,10 +8,14 @@ MEMORY_ID = os.getenv("{{memoryProviders.[0].envVarName}}") REGION = os.getenv("AWS_REGION") -def get_memory_session_manager(session_id: str, actor_id: str) -> Optional[AgentCoreMemorySessionManager]: +def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Optional[AgentCoreMemorySessionManager]: if not MEMORY_ID: return None + # AgentCoreMemoryConfig rejects None; OAuth/CUSTOM_JWT callers can reach us + # without a runtime session header, so synthesize one when absent. + session_id = session_id or uuid.uuid4().hex + {{#if memoryProviders.[0].strategies.length}} retrieval_config = { {{#if (includes memoryProviders.[0].strategies "SEMANTIC")}} diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 7b9eef675..72cf5c50d 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -26,6 +26,7 @@ import { LayoutProvider } from './tui/context'; import { COMMAND_DESCRIPTIONS } from './tui/copy'; import { clearExitAction, getExitAction } from './tui/exit-action'; import { clearExitMessage, getExitMessage } from './tui/exit-message'; +import { requireTTY } from './tui/guards'; import { CommandListScreen } from './tui/screens/home'; import { getCommandsForUI } from './tui/utils'; import { type UpdateCheckResult, checkForUpdate, printUpdateNotification } from './update-notifier'; @@ -212,6 +213,7 @@ export const main = async (argv: string[]) => { // Show TUI for no arguments, commander handles --help via configureHelp() if (args.length === 0) { + requireTTY(); renderTUI(updateCheck, isFirstRun); return; } diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 8151fcac8..28ced03c1 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -6,6 +6,7 @@ import type { OnlineEvalDeployedState, PolicyDeployedState, PolicyEngineDeployedState, + RuntimeEndpointDeployedState, TargetDeployedState, } from '../../schema'; import { getCredentialProvider } from '../aws'; @@ -338,6 +339,40 @@ export function parsePolicyOutputs( return policies; } +/** + * Parse stack outputs into deployed state for runtime endpoints. + * + * Output key pattern: ApplicationAgent{AgentPascal}Endpoint{AgentPascal}{EndpointPascal}(Id|Arn)Output{Hash} + * The Agent{PascalName} prefix comes from the AgentEnvironment construct in the CDK tree. + */ +export function parseRuntimeEndpointOutputs( + outputs: StackOutputs, + endpointSpecs: { agentName: string; endpointName: string }[] +): Record { + const endpoints: Record = {}; + const outputKeys = Object.keys(outputs); + + for (const { agentName, endpointName } of endpointSpecs) { + const agentPascal = toPascalId(agentName); + const endpointPascal = toPascalId('Endpoint', agentName, endpointName); + const idPrefix = `ApplicationAgent${agentPascal}${endpointPascal}IdOutput`; + const arnPrefix = `ApplicationAgent${agentPascal}${endpointPascal}ArnOutput`; + + const idKey = outputKeys.find(k => k.startsWith(idPrefix)); + const arnKey = outputKeys.find(k => k.startsWith(arnPrefix)); + + if (idKey && arnKey) { + const key = `${agentName}/${endpointName}`; + endpoints[key] = { + endpointId: outputs[idKey]!, + endpointArn: outputs[arnKey]!, + }; + } + } + + return endpoints; +} + export interface BuildDeployedStateOptions { targetName: string; stackName: string; @@ -351,6 +386,7 @@ export interface BuildDeployedStateOptions { onlineEvalConfigs?: Record; policyEngines?: Record; policies?: Record; + runtimeEndpoints?: Record; } /** @@ -370,6 +406,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta onlineEvalConfigs, policyEngines, policies, + runtimeEndpoints, } = opts; const targetState: TargetDeployedState = { resources: { @@ -404,6 +441,11 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta targetState.resources!.onlineEvalConfigs = onlineEvalConfigs; } + // Add runtime endpoint state if endpoints exist + if (runtimeEndpoints && Object.keys(runtimeEndpoints).length > 0) { + targetState.resources!.runtimeEndpoints = runtimeEndpoints; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index a94d8658e..934908301 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -1,5 +1,5 @@ import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; -import { requireProject } from '../../tui/guards'; +import { requireProject, requireTTY } from '../../tui/guards'; import { AddFlow } from '../../tui/screens/add/AddFlow'; import type { Command } from '@commander-js/extra-typings'; import { render } from 'ink'; @@ -21,6 +21,7 @@ export function registerAdd(program: Command): Command { } requireProject(); + requireTTY(); const { clear, unmount } = render( { expect(json.success).toBe(false); expect(json.error.includes('conflicts')).toBeTruthy(); }); + + it('creates project-only scaffold with --project-name and no --name', async () => { + const projectName = `ProjOnly${Date.now()}`; + const result = await runCLI(['create', '--project-name', projectName, '--no-agent', '--json'], testDir); + + expect(result.exitCode, `stderr: ${result.stderr}, stdout: ${result.stdout}`).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.projectPath).toMatch(new RegExp(`/${projectName}$`)); + expect(await exists(join(json.projectPath, 'agentcore'))).toBeTruthy(); + }); }); describe('with agent', () => { @@ -144,6 +156,44 @@ describe('create command', () => { expect(episodic?.namespaces).toEqual(['/episodes/{actorId}/{sessionId}']); expect(episodic?.reflectionNamespaces).toEqual(['/episodes/{actorId}']); }); + + it('uses --project-name for project and --name for agent resource', async () => { + const projectName = `AgentProj${Date.now().toString().slice(-6)}`; + const agentName = `AgentResource${randomUUID().replace(/-/g, '').slice(0, 16)}`; + const result = await runCLI( + [ + 'create', + '--project-name', + projectName, + '--name', + agentName, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--skip-git', + '--skip-install', + '--json', + ], + testDir + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.projectPath).toMatch(new RegExp(`/${projectName}$`)); + expect(json.agentName).toBe(agentName); + expect(await exists(join(json.projectPath, 'app', agentName))).toBeTruthy(); + + const projectSpec = JSON.parse(await readFile(join(json.projectPath, 'agentcore/agentcore.json'), 'utf-8')); + expect(projectSpec.name).toBe(projectName); + expect(projectSpec.runtimes[0].name).toBe(agentName); + }); }); describe('--defaults', () => { @@ -167,6 +217,21 @@ describe('create command', () => { expect(result.stdout.includes('would create') || result.stdout.includes('Dry run')).toBeTruthy(); expect(await exists(join(testDir, name)), 'Should not create directory').toBe(false); }); + + it('uses project-name for project paths and name for app paths', async () => { + const projectName = `DryProj${Date.now().toString().slice(-6)}`; + const agentName = `DryAgent${Date.now().toString().slice(-6)}`; + const result = await runCLI( + ['create', '--project-name', projectName, '--name', agentName, '--defaults', '--dry-run', '--json'], + testDir + ); + + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.projectPath).toMatch(new RegExp(`/${projectName}$`)); + expect(json.wouldCreate).toContain(`${json.projectPath}/app/${agentName}/`); + expect(await exists(join(testDir, projectName)), 'Should not create directory').toBe(false); + }); }); describe('--skip-git', () => { diff --git a/src/cli/commands/create/__tests__/validate.test.ts b/src/cli/commands/create/__tests__/validate.test.ts index e5938a964..8c118ebf5 100644 --- a/src/cli/commands/create/__tests__/validate.test.ts +++ b/src/cli/commands/create/__tests__/validate.test.ts @@ -35,6 +35,7 @@ describe('validateCreateOptions', () => { beforeAll(() => { testDir = join(tmpdir(), `create-opts-${randomUUID()}`); mkdirSync(testDir, { recursive: true }); + mkdirSync(join(testDir, 'ExistingProject'), { recursive: true }); }); afterAll(() => { @@ -59,6 +60,42 @@ describe('validateCreateOptions', () => { expect(result.error).toContain('already exists'); }); + it('validates projectName separately from agent name', () => { + const result = validateCreateOptions( + { + name: `Agent${'A'.repeat(30)}`, + projectName: 'ShortProject', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }, + testDir + ); + expect(result.valid).toBe(true); + }); + + it('checks folder existence using projectName', () => { + const result = validateCreateOptions( + { + name: 'AgentName', + projectName: 'ExistingProject', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('ExistingProject'); + }); + + it('allows project-only create with only projectName', () => { + const result = validateCreateOptions({ projectName: 'OnlyProject', agent: false }, testDir); + expect(result.valid).toBe(true); + }); + it('returns valid with --no-agent flag', () => { const result = validateCreateOptions({ name: 'NoAgentProject', agent: false }, testDir); expect(result.valid).toBe(true); diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index 59126d66f..dbfc215d7 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -111,6 +111,7 @@ type MemoryOption = 'none' | 'shortTerm' | 'longAndShortTerm'; export interface CreateWithAgentOptions { name: string; + projectName?: string; cwd: string; type?: 'create' | 'import'; buildType?: BuildType; @@ -139,6 +140,7 @@ export interface CreateWithAgentOptions { export async function createProjectWithAgent(options: CreateWithAgentOptions): Promise { const { name, + projectName: explicitProjectName, cwd, buildType, language, @@ -159,7 +161,8 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P skipPythonSetup, onProgress, } = options; - const projectRoot = join(cwd, name); + const projectName = explicitProjectName ?? name; + const projectRoot = join(cwd, projectName); const configBaseDir = join(projectRoot, CONFIG_DIR); // Check CLI dependencies first (with language for conditional uv check) @@ -172,7 +175,14 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P } // First create the base project (skip dependency check since we already did it) - const projectResult = await createProject({ name, cwd, skipGit, skipInstall, skipDependencyCheck: true, onProgress }); + const projectResult = await createProject({ + name: projectName, + cwd, + skipGit, + skipInstall, + skipDependencyCheck: true, + onProgress, + }); if (!projectResult.success) { // Merge warnings from both checks const allWarnings = [...depWarnings, ...(projectResult.warnings ?? [])]; @@ -243,7 +253,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P if (!isMcp && resolvedModelProvider !== 'Bedrock') { strategy = await credentialPrimitive.resolveCredentialStrategy( - name, + projectName, agentName, resolvedModelProvider, apiKey, @@ -295,9 +305,15 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P } } -export function getDryRunInfo(options: { name: string; cwd: string; language?: string }): CreateResult { +export function getDryRunInfo(options: { + name: string; + cwd: string; + language?: string; + projectName?: string; +}): CreateResult { const { name, cwd, language } = options; - const projectRoot = join(cwd, name); + const projectName = options.projectName ?? name; + const projectRoot = join(cwd, projectName); const wouldCreate = [ `${projectRoot}/`, diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index e9da92bcb..ac9d4b3ae 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -10,6 +10,7 @@ import type { import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema'; import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; +import { requireTTY } from '../../tui/guards'; import { CreateScreen } from '../../tui/screens/create'; import { parseCommaSeparatedList } from '../shared/vpc-utils'; import { type ProgressCallback, createProject, createProjectWithAgent, getDryRunInfo } from './action'; @@ -75,6 +76,8 @@ function printCreateSummary( /** Handle CLI mode with progress output */ async function handleCreateCLI(options: CreateOptions): Promise { const cwd = options.outputDir ?? getWorkingDirectory(); + const name = options.name ?? options.projectName; + const projectName = options.projectName ?? name; const validation = validateCreateOptions(options, cwd); if (!validation.valid) { @@ -88,7 +91,7 @@ async function handleCreateCLI(options: CreateOptions): Promise { // Handle dry-run mode if (options.dryRun) { - const result = getDryRunInfo({ name: options.name!, cwd, language: options.language }); + const result = getDryRunInfo({ name: name!, projectName, cwd, language: options.language }); if (options.json) { console.log(JSON.stringify(result)); } else { @@ -120,14 +123,15 @@ async function handleCreateCLI(options: CreateOptions): Promise { const result = skipAgent ? await createProject({ - name: options.name!, + name: projectName!, cwd, skipGit: options.skipGit, skipInstall: options.skipInstall, onProgress, }) : await createProjectWithAgent({ - name: options.name!, + name: name!, + projectName, cwd, type: options.type as 'create' | 'import' | undefined, buildType: (options.build as BuildType) ?? 'CodeZip', @@ -155,7 +159,7 @@ async function handleCreateCLI(options: CreateOptions): Promise { if (options.json) { console.log(JSON.stringify(result)); } else if (result.success) { - printCreateSummary(options.name!, result.agentName, options.language, options.framework); + printCreateSummary(projectName!, result.agentName, options.language, options.framework); if (options.skipInstall) { console.log( "\nDependency installation was skipped. Run 'npm install' in agentcore/cdk/ and 'uv sync' in your agent directory manually." @@ -172,7 +176,11 @@ export const registerCreate = (program: Command) => { program .command('create') .description(COMMAND_DESCRIPTIONS.create) - .option('--name ', 'Project name (start with letter, alphanumeric only, max 23 chars) [non-interactive]') + .option('--name ', 'Resource name (agent or harness) [non-interactive]') + .option( + '--project-name ', + 'Project name (start with letter, alphanumeric only, max 23 chars) [non-interactive]' + ) .option('--no-agent', 'Skip agent creation [non-interactive]') .option('--defaults', 'Use defaults (Python, Strands, Bedrock, no memory) [non-interactive]') .option('--build ', 'Build type: CodeZip or Container (default: CodeZip) [non-interactive]') @@ -224,6 +232,7 @@ export const registerCreate = (program: Command) => { // Any flag triggers non-interactive CLI mode const hasAnyFlag = Boolean( options.name ?? + options.projectName ?? (options.agent === false ? true : null) ?? options.defaults ?? options.build ?? @@ -245,6 +254,7 @@ export const registerCreate = (program: Command) => { options.language = options.language ?? 'Python'; await handleCreateCLI(options as CreateOptions); } else { + requireTTY(); handleCreateTUI(); } } catch (error) { diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index 26b83b200..f870ec1fc 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -2,6 +2,7 @@ import type { VpcOptions } from '../shared/vpc-utils'; export interface CreateOptions extends VpcOptions { name?: string; + projectName?: string; agent?: boolean; defaults?: boolean; type?: string; diff --git a/src/cli/commands/create/validate.ts b/src/cli/commands/create/validate.ts index 7d2026ce3..a59c7d752 100644 --- a/src/cli/commands/create/validate.ts +++ b/src/cli/commands/create/validate.ts @@ -1,4 +1,5 @@ import { + AgentNameSchema, BuildTypeSchema, ModelProviderSchema, ProjectNameSchema, @@ -36,18 +37,20 @@ export function validateFolderNotExists(name: string, cwd: string): true | strin export function validateCreateOptions(options: CreateOptions, cwd?: string): ValidationResult { // Name is required for non-interactive mode - if (!options.name) { + if (!options.name && !(options.agent === false && options.projectName)) { return { valid: false, error: '--name is required' }; } - // Validate name format - const nameResult = ProjectNameSchema.safeParse(options.name); - if (!nameResult.success) { - return { valid: false, error: nameResult.error.issues[0]?.message ?? 'Invalid project name' }; + const projectName = options.projectName ?? options.name!; + + // Validate project name format + const projectNameResult = ProjectNameSchema.safeParse(projectName); + if (!projectNameResult.success) { + return { valid: false, error: projectNameResult.error.issues[0]?.message ?? 'Invalid project name' }; } // Check if directory already exists - const folderCheck = validateFolderNotExists(options.name, cwd ?? process.cwd()); + const folderCheck = validateFolderNotExists(projectName, cwd ?? process.cwd()); if (folderCheck !== true) { return { valid: false, error: folderCheck }; } @@ -57,6 +60,11 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val return { valid: true }; } + const agentNameResult = AgentNameSchema.safeParse(options.name); + if (!agentNameResult.success) { + return { valid: false, error: agentNameResult.error.issues[0]?.message ?? 'Invalid agent name' }; + } + // Import path: validate import-specific options if (options.type === 'import') { if (!options.agentId) return { valid: false, error: '--agent-id is required for import' }; diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index b3d7ad55b..ed88c1dfe 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -13,6 +13,7 @@ import { parseOnlineEvalOutputs, parsePolicyEngineOutputs, parsePolicyOutputs, + parseRuntimeEndpointOutputs, } from '../../cloudformation'; import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; @@ -403,6 +404,17 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { await handleDeployCLI(options as DeployOptions); } else if (cliOptions.diff) { // Diff-only: use TUI with diff mode + requireTTY(); handleDeployTUI({ diffMode: true }); } else { + requireTTY(); handleDeployTUI(); } } catch (error) { diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index fadcfc54e..eac485d8c 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -21,7 +21,7 @@ import { OtelCollector, startOtelCollector } from '../../operations/dev/otel'; import { FatalError } from '../../tui/components'; import { LayoutProvider } from '../../tui/context'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; -import { requireProject } from '../../tui/guards'; +import { requireProject, requireTTY } from '../../tui/guards'; import { parseHeaderFlags } from '../shared/header-utils'; import { runBrowserMode } from './browser-mode'; import type { Command } from '@commander-js/extra-typings'; @@ -383,6 +383,7 @@ export const registerDev = (program: Command) => { // If --no-browser provided, launch terminal TUI mode if (!opts.browser) { + requireTTY(); // Enter alternate screen buffer for fullscreen mode process.stdout.write(ENTER_ALT_SCREEN); diff --git a/src/cli/commands/import/__tests__/import-memory.test.ts b/src/cli/commands/import/__tests__/import-memory.test.ts index b4b084a0c..fbd717e54 100644 --- a/src/cli/commands/import/__tests__/import-memory.test.ts +++ b/src/cli/commands/import/__tests__/import-memory.test.ts @@ -38,7 +38,7 @@ function toMemorySpec(mem: ParsedStarterToolkitMemory): Memory { return { name: mem.name, - eventExpiryDuration: Math.max(7, Math.min(365, mem.eventExpiryDays)), + eventExpiryDuration: Math.max(3, Math.min(365, mem.eventExpiryDays)), strategies, }; } @@ -408,7 +408,7 @@ describe('toMemorySpec', () => { }; const result = toMemorySpec(mem); - expect(result.eventExpiryDuration).toBe(7); + expect(result.eventExpiryDuration).toBe(3); }); it('clamps zero to minimum of 7', () => { @@ -419,7 +419,7 @@ describe('toMemorySpec', () => { }; const result = toMemorySpec(mem); - expect(result.eventExpiryDuration).toBe(7); + expect(result.eventExpiryDuration).toBe(3); }); it('clamps negative values to minimum of 7', () => { @@ -430,7 +430,7 @@ describe('toMemorySpec', () => { }; const result = toMemorySpec(mem); - expect(result.eventExpiryDuration).toBe(7); + expect(result.eventExpiryDuration).toBe(3); }); it('clamps high values to maximum of 365', () => { @@ -473,7 +473,7 @@ describe('YAML Parsing: eventExpiryDays values', () => { // But toMemorySpec should clamp it const spec = toMemorySpec(parsed.memories[0]!); - expect(spec.eventExpiryDuration).toBe(7); + expect(spec.eventExpiryDuration).toBe(3); } finally { fs.unlinkSync(tmpFile); } diff --git a/src/cli/commands/import/__tests__/merge-logic.test.ts b/src/cli/commands/import/__tests__/merge-logic.test.ts index 580e39c48..9b64ee20e 100644 --- a/src/cli/commands/import/__tests__/merge-logic.test.ts +++ b/src/cli/commands/import/__tests__/merge-logic.test.ts @@ -39,7 +39,7 @@ function toMemorySpec(mem: ParsedStarterToolkitConfig['memories'][0]): Memory { } return { name: mem.name, - eventExpiryDuration: Math.max(7, Math.min(365, mem.eventExpiryDays)), + eventExpiryDuration: Math.max(3, Math.min(365, mem.eventExpiryDays)), strategies, }; } @@ -194,7 +194,7 @@ describe('source copy skip logic', () => { describe('toMemorySpec', () => { it('clamps below 7', () => { const mem: ParsedStarterToolkitConfig['memories'][0] = { name: 't', mode: 'STM_ONLY', eventExpiryDays: 1 }; - expect(toMemorySpec(mem).eventExpiryDuration).toBe(7); + expect(toMemorySpec(mem).eventExpiryDuration).toBe(3); }); it('clamps above 365', () => { const mem: ParsedStarterToolkitConfig['memories'][0] = { name: 't', mode: 'STM_ONLY', eventExpiryDays: 999 }; diff --git a/src/cli/commands/import/actions.ts b/src/cli/commands/import/actions.ts index 248216572..a57685b72 100644 --- a/src/cli/commands/import/actions.ts +++ b/src/cli/commands/import/actions.ts @@ -78,7 +78,7 @@ function toMemorySpec(mem: ParsedStarterToolkitConfig['memories'][0]): Memory { return { name: mem.name, - eventExpiryDuration: Math.max(7, Math.min(365, mem.eventExpiryDays)), + eventExpiryDuration: Math.max(3, Math.min(365, mem.eventExpiryDays)), strategies, }; } diff --git a/src/cli/commands/import/command.ts b/src/cli/commands/import/command.ts index 3fd4f745d..01ada8915 100644 --- a/src/cli/commands/import/command.ts +++ b/src/cli/commands/import/command.ts @@ -12,7 +12,7 @@ const { green, yellow, cyan, dim, reset } = ANSI; export const registerImport = (program: Command) => { const importCmd = program .command('import') - .description('Import a runtime, memory, or starter toolkit into this project. [experimental]'); + .description('Import a runtime, memory, or starter toolkit into this project.'); // Existing YAML flow: agentcore import --source importCmd diff --git a/src/cli/commands/import/import-memory.ts b/src/cli/commands/import/import-memory.ts index 81740b726..2362d1353 100644 --- a/src/cli/commands/import/import-memory.ts +++ b/src/cli/commands/import/import-memory.ts @@ -55,7 +55,7 @@ function toMemorySpec(memory: MemoryDetail, localName: string): Memory { return { name: localName, - eventExpiryDuration: Math.max(7, Math.min(365, memory.eventExpiryDuration)), + eventExpiryDuration: Math.max(3, Math.min(365, memory.eventExpiryDuration)), strategies, ...(memory.tags && Object.keys(memory.tags).length > 0 && { tags: memory.tags }), ...(memory.encryptionKeyArn && { encryptionKeyArn: memory.encryptionKeyArn }), diff --git a/src/cli/commands/invoke/__tests__/resolve-prompt.test.ts b/src/cli/commands/invoke/__tests__/resolve-prompt.test.ts new file mode 100644 index 000000000..0b2c8daa1 --- /dev/null +++ b/src/cli/commands/invoke/__tests__/resolve-prompt.test.ts @@ -0,0 +1,88 @@ +import { resolvePrompt } from '../resolve-prompt'; +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +describe('resolvePrompt', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), `resolve-prompt-${randomUUID()}-`)); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('returns --prompt flag value when provided', async () => { + const result = await resolvePrompt({ flag: 'hello', stdinPiped: false }); + expect(result).toEqual({ success: true, prompt: 'hello' }); + }); + + it('prefers --prompt flag over positional, file, and stdin', async () => { + const file = join(dir, 'p.txt'); + await writeFile(file, 'from-file'); + const result = await resolvePrompt( + { flag: 'from-flag', positional: 'from-positional', file, stdinPiped: true }, + Readable.from(['from-stdin']) + ); + expect(result).toEqual({ success: true, prompt: 'from-flag' }); + }); + + it('prefers --prompt over positional', async () => { + const result = await resolvePrompt({ flag: 'from-flag', positional: 'from-positional', stdinPiped: false }); + expect(result).toEqual({ success: true, prompt: 'from-flag' }); + }); + + it('falls back to positional when no flag', async () => { + const result = await resolvePrompt({ positional: 'from-positional', stdinPiped: false }); + expect(result).toEqual({ success: true, prompt: 'from-positional' }); + }); + + it('reads from --prompt-file when no flag or positional', async () => { + const file = join(dir, 'p.txt'); + await writeFile(file, 'content from file\n'); + const result = await resolvePrompt({ file, stdinPiped: false }); + expect(result).toEqual({ success: true, prompt: 'content from file' }); + }); + + it('strips only one trailing newline from file content', async () => { + const file = join(dir, 'p.txt'); + await writeFile(file, 'line1\nline2\n\n'); + const result = await resolvePrompt({ file, stdinPiped: false }); + expect(result.prompt).toBe('line1\nline2\n'); + }); + + it('reads from stdin when piped and no other source', async () => { + const result = await resolvePrompt({ stdinPiped: true }, Readable.from(['piped input\n'])); + expect(result).toEqual({ success: true, prompt: 'piped input' }); + }); + + it('errors when --prompt-file and stdin are both present', async () => { + const file = join(dir, 'p.txt'); + await writeFile(file, 'x'); + const result = await resolvePrompt({ file, stdinPiped: true }, Readable.from(['y'])); + expect(result.success).toBe(false); + expect(result.error).toContain('--prompt-file'); + expect(result.error).toContain('stdin'); + }); + + it('returns failure when --prompt-file does not exist', async () => { + const result = await resolvePrompt({ file: join(dir, 'missing.txt'), stdinPiped: false }); + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to read --prompt-file'); + }); + + it('returns undefined prompt when no source is provided', async () => { + const result = await resolvePrompt({ stdinPiped: false }); + expect(result).toEqual({ success: true, prompt: undefined }); + }); + + it('preserves empty-string flag (does not fall through)', async () => { + const result = await resolvePrompt({ flag: '', positional: 'ignored', stdinPiped: false }); + expect(result).toEqual({ success: true, prompt: '' }); + }); +}); diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index 41b19c0f3..77d7fdccd 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -14,6 +14,7 @@ import { import { InvokeLogger } from '../../logging'; import { formatMcpToolList } from '../../operations/dev/utils'; import { canFetchRuntimeToken, fetchRuntimeToken } from '../../operations/fetch-access'; +import { generateSessionId } from '../../operations/session'; import type { InvokeOptions, InvokeResult } from './types'; export interface InvokeContext { @@ -114,6 +115,14 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption } } + // When invoking with a bearer token (OAuth/CUSTOM_JWT), AgentCore does not + // auto-generate a runtime session ID the way it does for SigV4 callers. Templates + // that wire up AgentCoreMemorySessionManager require a non-null session_id, so + // generate one here if the caller didn't pass --session-id. + if (options.bearerToken && !options.sessionId) { + options = { ...options, sessionId: generateSessionId() }; + } + // Exec mode: run shell command in runtime container if (options.exec) { const logger = new InvokeLogger({ @@ -369,6 +378,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption agentName: agentSpec.name, targetName: selectedTargetName, response, + sessionId: aguiResult.sessionId, logFilePath: logger.logFilePath, }; } catch (err) { @@ -415,6 +425,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption agentName: agentSpec.name, targetName: selectedTargetName, response: fullResponse, + sessionId: result.sessionId, logFilePath: logger.logFilePath, }; } catch (err) { @@ -441,6 +452,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption agentName: agentSpec.name, targetName: selectedTargetName, response: response.content, + sessionId: response.sessionId, logFilePath: logger.logFilePath, }; } diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 6243d90f2..cc0cd1e35 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -1,9 +1,10 @@ import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; -import { requireProject } from '../../tui/guards'; +import { requireProject, requireTTY } from '../../tui/guards'; import { InvokeScreen } from '../../tui/screens/invoke'; import { parseHeaderFlags } from '../shared/header-utils'; import { handleInvoke, loadInvokeConfig } from './action'; +import { resolvePrompt } from './resolve-prompt'; import type { InvokeOptions } from './types'; import { validateInvokeOptions } from './validate'; import type { Command } from '@commander-js/extra-typings'; @@ -56,9 +57,13 @@ async function handleInvokeCLI(options: InvokeOptions): Promise { if (options.json) { console.log(JSON.stringify(result)); } else if (options.stream) { - // Streaming already wrote to stdout, just show log path + // Streaming already wrote to stdout, just show session and log path + if (result.sessionId) { + console.error(`\nSession: ${result.sessionId}`); + console.error(`To resume: agentcore invoke --session-id ${result.sessionId}`); + } if (result.logFilePath) { - console.error(`\nLog: ${result.logFilePath}`); + console.error(`Log: ${result.logFilePath}`); } } else { // Non-streaming, non-json: print provider info and response or error @@ -67,8 +72,12 @@ async function handleInvokeCLI(options: InvokeOptions): Promise { } else if (!result.success && result.error) { console.error(result.error); } + if (result.sessionId) { + console.error(`\nSession: ${result.sessionId}`); + console.error(`To resume: agentcore invoke --session-id ${result.sessionId}`); + } if (result.logFilePath) { - console.error(`\nLog: ${result.logFilePath}`); + console.error(`Log: ${result.logFilePath}`); } } @@ -91,8 +100,15 @@ export const registerInvoke = (program: Command) => { .command('invoke') .alias('i') .description(COMMAND_DESCRIPTIONS.invoke) - .argument('[prompt]', 'Prompt to send to the agent [non-interactive]') + .argument( + '[prompt]', + 'Prompt to send to the agent. Also accepts piped stdin when no prompt is provided and stdin is not a TTY [non-interactive]' + ) .option('--prompt ', 'Prompt to send to the agent [non-interactive]') + .option( + '--prompt-file ', + 'Read the prompt from a file (for long or structured payloads that exceed shell arg limits) [non-interactive]' + ) .option('--runtime ', 'Select specific runtime [non-interactive]') .option('--target ', 'Select deployment target [non-interactive]') .option('--session-id ', 'Use specific session ID for conversation continuity') @@ -115,6 +131,7 @@ export const registerInvoke = (program: Command) => { positionalPrompt: string | undefined, cliOptions: { prompt?: string; + promptFile?: string; runtime?: string; target?: string; sessionId?: string; @@ -131,8 +148,22 @@ export const registerInvoke = (program: Command) => { ) => { try { requireProject(); - // --prompt flag takes precedence over positional argument - const prompt = cliOptions.prompt ?? positionalPrompt; + // Resolve prompt from flag / positional / --prompt-file / stdin + const resolved = await resolvePrompt({ + flag: cliOptions.prompt, + positional: positionalPrompt, + file: cliOptions.promptFile, + stdinPiped: !process.stdin.isTTY, + }); + if (!resolved.success) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: resolved.error })); + } else { + console.error(resolved.error); + } + process.exit(1); + } + const prompt = resolved.prompt; // Parse custom headers let headers: Record | undefined; @@ -142,7 +173,7 @@ export const registerInvoke = (program: Command) => { // CLI mode if any CLI-specific options provided (follows deploy command pattern) if ( - prompt || + prompt !== undefined || cliOptions.json || cliOptions.target || cliOptions.stream || @@ -168,6 +199,7 @@ export const registerInvoke = (program: Command) => { }); } else { // No CLI options - interactive TUI mode (headers still passed if provided) + requireTTY(); const { waitUntilExit, unmount } = render( { + try { + const content = await readFile(path, 'utf-8'); + return { success: true, prompt: content.replace(/\r?\n$/, '') }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: `Failed to read --prompt-file '${path}': ${message}` }; + } +} + +async function readStdin(stdin: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stdin) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks).toString('utf-8').trim(); +} + +/** + * Resolves the effective prompt from multiple possible sources. + * + * Precedence (hybrid — backward compatible with existing --prompt/positional behavior): + * 1. --prompt flag + * 2. positional argument + * 3. --prompt-file + * 4. stdin (when piped) + * + * Collision rule: --prompt-file AND piped stdin together is an error, since silent + * precedence between two "bulk" sources would mask user mistakes (e.g. a CI pipeline + * accidentally piping data while also passing --prompt-file). + */ +export async function resolvePrompt( + sources: PromptSources, + stdin: NodeJS.ReadableStream = process.stdin +): Promise { + if (sources.flag !== undefined) return { success: true, prompt: sources.flag }; + if (sources.positional !== undefined) return { success: true, prompt: sources.positional }; + + const stdinContent = sources.stdinPiped ? await readStdin(stdin) : ''; + const hasStdinContent = stdinContent.length > 0; + + if (sources.file !== undefined && hasStdinContent) { + return { + success: false, + error: 'Cannot combine --prompt-file with piped stdin. Provide only one prompt source.', + }; + } + if (sources.file !== undefined) return readPromptFile(sources.file); + if (hasStdinContent) return { success: true, prompt: stdinContent }; + return { success: true, prompt: undefined }; +} diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts index 8d8175095..61401c332 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -2,6 +2,8 @@ export interface InvokeOptions { agentName?: string; targetName?: string; prompt?: string; + /** Path to a file containing the prompt (alternative to --prompt / positional) */ + promptFile?: string; sessionId?: string; userId?: string; json?: boolean; @@ -25,6 +27,7 @@ export interface InvokeResult { agentName?: string; targetName?: string; response?: string; + sessionId?: string; error?: string; logFilePath?: string; } diff --git a/src/cli/commands/remove/__tests__/subcommand-priority.test.ts b/src/cli/commands/remove/__tests__/subcommand-priority.test.ts index bbba07e6c..6dc0a4672 100644 --- a/src/cli/commands/remove/__tests__/subcommand-priority.test.ts +++ b/src/cli/commands/remove/__tests__/subcommand-priority.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; // Mock registry to break circular dependency vi.mock('../../../primitives/registry', () => ({ @@ -21,6 +21,8 @@ vi.mock('../../../../lib/index.js', () => ({ * but this test ensures that contract holds if the registration pattern changes. */ describe('remove subcommand priority', () => { + afterEach(() => vi.restoreAllMocks()); + it('named subcommands are matched before the catch-all', async () => { const { Command } = await import('@commander-js/extra-typings'); const { registerRemove } = await import('../command.js'); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index deb1a9274..e978e9793 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -1,7 +1,7 @@ import { ConfigIO } from '../../../lib'; import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; -import { requireProject } from '../../tui/guards'; +import { requireProject, requireTTY } from '../../tui/guards'; import { RemoveAllScreen, RemoveFlow } from '../../tui/screens/remove'; import type { RemoveAllOptions, RemoveResult } from './types'; import { validateRemoveAllOptions } from './validate'; @@ -76,6 +76,7 @@ export const registerRemove = (program: Command): Command => { json: cliOptions.json, }); } else { + requireTTY(); const { unmount } = render( { } requireProject(); + requireTTY(); const { clear, unmount } = render( ({ getIdentifier, getLocalDetail, getDeployedKey, + getParentName, }: { resourceType: ResourceStatusEntry['resourceType']; localItems: TLocal[]; @@ -86,6 +89,7 @@ function diffResourceSet({ getIdentifier: (deployed: TDeployed) => string | undefined; getLocalDetail?: (item: TLocal) => string | undefined; getDeployedKey?: (item: TLocal) => string; + getParentName?: (item: TLocal) => string | undefined; }): ResourceStatusEntry[] { const entries: ResourceStatusEntry[] = []; const localKeys = new Set(localItems.map(item => (getDeployedKey ? getDeployedKey(item) : item.name))); @@ -99,16 +103,20 @@ function diffResourceSet({ deploymentState: deployed ? 'deployed' : 'local-only', identifier: deployed ? getIdentifier(deployed) : undefined, detail: getLocalDetail?.(item), + parentName: getParentName?.(item), }); } for (const [name, deployed] of Object.entries(deployedRecord)) { if (!localKeys.has(name)) { + // For pending-removal entries, try to extract parentName from composite key + const slashIdx = name.indexOf('/'); entries.push({ resourceType, name, deploymentState: 'pending-removal', identifier: getIdentifier(deployed), + parentName: getParentName && slashIdx > 0 ? name.substring(0, slashIdx) : undefined, }); } } @@ -202,8 +210,34 @@ export function computeResourceStatuses( getDeployedKey: item => `${item.engineName}/${item.name}`, }); + // Flatten runtime endpoints for diffing against deployed state + const localEndpoints: { name: string; agentName: string; version: number; description?: string }[] = []; + for (const runtime of project.runtimes) { + if (runtime.endpoints) { + for (const [epName, ep] of Object.entries(runtime.endpoints)) { + localEndpoints.push({ + name: epName, + agentName: runtime.name, + version: ep.version, + description: ep.description, + }); + } + } + } + + const runtimeEndpoints = diffResourceSet({ + resourceType: 'runtime-endpoint', + localItems: localEndpoints, + deployedRecord: resources?.runtimeEndpoints ?? {}, + getIdentifier: deployed => deployed.endpointArn, + getLocalDetail: item => `v${item.version}${item.description ? ` — ${item.description}` : ''}`, + getDeployedKey: item => `${item.agentName}/${item.name}`, + getParentName: item => item.agentName, + }); + return [ ...agents, + ...runtimeEndpoints, ...credentials, ...memories, ...gateways, diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index 9e6de5e37..76c4580ea 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -9,6 +9,7 @@ import { Box, Text, render } from 'ink'; const VALID_RESOURCE_TYPES = [ 'agent', + 'runtime-endpoint', 'memory', 'credential', 'gateway', @@ -58,7 +59,7 @@ export const registerStatus = (program: Command) => { .option('--target ', 'Select deployment target') .option( '--type ', - 'Filter by resource type (agent, memory, credential, gateway, evaluator, online-eval, policy-engine, policy)' + 'Filter by resource type (agent, runtime-endpoint, memory, credential, gateway, evaluator, online-eval, policy-engine, policy)' ) .option('--state ', 'Filter by deployment state (deployed, local-only, pending-removal)') .option('--runtime ', 'Filter to a specific runtime') @@ -135,6 +136,7 @@ export const registerStatus = (program: Command) => { const filtered = filterResources(result.resources, cliOptions); const agents = filtered.filter(r => r.resourceType === 'agent'); + const runtimeEndpoints = filtered.filter(r => r.resourceType === 'runtime-endpoint'); const credentials = filtered.filter(r => r.resourceType === 'credential'); const memories = filtered.filter(r => r.resourceType === 'memory'); const gateways = filtered.filter(r => r.resourceType === 'gateway'); @@ -153,15 +155,41 @@ export const registerStatus = (program: Command) => { {agents.length > 0 && ( Agents - {agents.map(entry => ( - - - {entry.invocationUrl && ( - - {' '}URL: {entry.invocationUrl} - - )} - + {agents.map(entry => { + // Find endpoints belonging to this agent + const agentEndpoints = runtimeEndpoints.filter(ep => ep.parentName === entry.name); + return ( + + + {entry.invocationUrl && ( + + {' '}URL: {entry.invocationUrl} + + )} + {agentEndpoints.map(ep => ( + + {' '}◉ {ep.name} {ep.detail}{' '} + + [{DEPLOYMENT_STATE_LABELS[ep.deploymentState] ?? ep.deploymentState}] + + + ))} + + ); + })} + + )} + + {agents.length === 0 && runtimeEndpoints.length > 0 && ( + + Runtime Endpoints + {runtimeEndpoints.map(ep => ( + + {' '}◉ {ep.parentName}/{ep.name} {ep.detail}{' '} + + [{DEPLOYMENT_STATE_LABELS[ep.deploymentState] ?? ep.deploymentState}] + + ))} )} diff --git a/src/cli/commands/telemetry/actions.ts b/src/cli/commands/telemetry/actions.ts index 3e1d09697..90750a0f6 100644 --- a/src/cli/commands/telemetry/actions.ts +++ b/src/cli/commands/telemetry/actions.ts @@ -1,5 +1,5 @@ import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js'; -import { resolveTelemetryPreference } from '../../telemetry/resolve.js'; +import { resolveTelemetryPreference } from '../../telemetry/config.js'; export async function handleTelemetryDisable( configDir = GLOBAL_CONFIG_DIR, diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index 2bfffcaa4..9ec7e43ad 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -13,6 +13,7 @@ export interface RemoveLoggerOptions { | 'credential' | 'gateway' | 'gateway-target' + | 'runtime-endpoint' | 'evaluator' | 'online-eval' | 'policy-engine' diff --git a/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts b/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts index e5a9b6566..35e06ecd5 100644 --- a/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts @@ -2,7 +2,7 @@ import { CodeZipDevServer } from '../codezip-dev-server'; import type { DevConfig } from '../config'; import type { DevServerCallbacks, DevServerOptions } from '../dev-server'; import { EventEmitter } from 'events'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockSpawn = vi.fn(); vi.mock('child_process', () => ({ @@ -35,6 +35,8 @@ describe('CodeZipDevServer spawn config', () => { mockSpawn.mockReturnValue(createMockChildProcess()); }); + afterEach(() => vi.restoreAllMocks()); + it('HTTP: uses uvicorn with --reload', async () => { const config: DevConfig = { agentName: 'HttpAgent', diff --git a/src/cli/operations/dev/__tests__/utils-open-browser.test.ts b/src/cli/operations/dev/__tests__/utils-open-browser.test.ts new file mode 100644 index 000000000..dfb69359a --- /dev/null +++ b/src/cli/operations/dev/__tests__/utils-open-browser.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockUnref = vi.fn(); +const mockSpawn = vi.fn().mockReturnValue({ unref: mockUnref }); + +vi.mock('child_process', () => ({ + spawn: (...args: unknown[]) => mockSpawn(...args), +})); + +describe('openBrowser', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + mockSpawn.mockClear(); + mockUnref.mockClear(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('uses "open" on macOS', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const { openBrowser } = await import('../utils'); + openBrowser('http://localhost:3000'); + + expect(mockSpawn).toHaveBeenCalledWith('open', ['http://localhost:3000'], { + stdio: 'ignore', + detached: true, + }); + expect(mockUnref).toHaveBeenCalled(); + }); + + it('uses "cmd /c start" on Windows to avoid ENOENT', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + const { openBrowser } = await import('../utils'); + openBrowser('http://localhost:3000'); + + expect(mockSpawn).toHaveBeenCalledWith('cmd', ['/c', 'start', 'http://localhost:3000'], { + stdio: 'ignore', + detached: true, + }); + expect(mockUnref).toHaveBeenCalled(); + }); + + it('uses "xdg-open" on Linux', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + const { openBrowser } = await import('../utils'); + openBrowser('http://localhost:3000'); + + expect(mockSpawn).toHaveBeenCalledWith('xdg-open', ['http://localhost:3000'], { + stdio: 'ignore', + detached: true, + }); + expect(mockUnref).toHaveBeenCalled(); + }); +}); diff --git a/src/cli/operations/dev/utils.ts b/src/cli/operations/dev/utils.ts index eda1c2684..d0810727a 100644 --- a/src/cli/operations/dev/utils.ts +++ b/src/cli/operations/dev/utils.ts @@ -1,3 +1,4 @@ +import { spawn } from 'child_process'; import { createConnection, createServer } from 'net'; /** Check if a port is available on a specific host */ @@ -108,3 +109,10 @@ export function convertEntrypointToModule(entrypoint: string): string { const path = entrypoint.replace(/\.py$/, '').replace(/\//g, '.'); return `${path}:app`; } + +export function openBrowser(url: string): void { + const isWindows = process.platform === 'win32'; + const cmd = isWindows ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open'; + const args = isWindows ? ['/c', 'start', url] : [url]; + spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); +} diff --git a/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts b/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts new file mode 100644 index 000000000..710281293 --- /dev/null +++ b/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts @@ -0,0 +1,62 @@ +import { resolveUIDistDir } from '../web-server.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs'); + +const existsSync = vi.mocked(fs.existsSync); + +describe('resolveUIDistDir', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.AGENT_INSPECTOR_PATH; + existsSync.mockReturnValue(false); + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('returns null when no candidate has index.html', () => { + expect(resolveUIDistDir()).toBeNull(); + }); + + it('returns AGENT_INSPECTOR_PATH when env var is set and dir has index.html', () => { + const customPath = '/custom/inspector/dist'; + process.env.AGENT_INSPECTOR_PATH = customPath; + + existsSync.mockImplementation(p => p === path.join(customPath, 'index.html')); + + expect(resolveUIDistDir()).toBe(customPath); + }); + + it('skips AGENT_INSPECTOR_PATH when env var is set but dir lacks index.html', () => { + process.env.AGENT_INSPECTOR_PATH = '/missing/inspector'; + existsSync.mockReturnValue(false); + + expect(resolveUIDistDir()).toBeNull(); + }); + + it('returns the first candidate that has index.html', () => { + existsSync.mockImplementation(p => { + return String(p).endsWith(path.join('agent-inspector', 'index.html')); + }); + + const result = resolveUIDistDir(); + expect(result).not.toBeNull(); + expect(result!).toMatch(/agent-inspector$/); + }); + + it('prefers AGENT_INSPECTOR_PATH over bundled candidates', () => { + const customPath = '/custom/path'; + process.env.AGENT_INSPECTOR_PATH = customPath; + + existsSync.mockReturnValue(true); + + expect(resolveUIDistDir()).toBe(customPath); + }); +}); diff --git a/src/cli/operations/dev/web-ui/handlers/start.ts b/src/cli/operations/dev/web-ui/handlers/start.ts index 7fea6b742..41857bda1 100644 --- a/src/cli/operations/dev/web-ui/handlers/start.ts +++ b/src/cli/operations/dev/web-ui/handlers/start.ts @@ -170,10 +170,9 @@ async function doStartAgent( return { success: false, name: agentName, port: 0, error: errorMsg }; } - ctx.runningAgents.set(agentName, { server: agentServer, port: agentPort, protocol: config.protocol }); - - // Wait for the server to actually accept connections before telling the - // frontend it's ready — otherwise immediate invocations get ECONNREFUSED. + // Wait for the server to accept connections before adding to runningAgents. + // runningAgents gates /api/status, so adding early lets the frontend send + // invocations before the server is ready. const ready = await waitForServerReady(agentPort); if (!ready) { const errorMsg = @@ -182,5 +181,6 @@ async function doStartAgent( return { success: false, name: agentName, port: 0, error: errorMsg }; } + ctx.runningAgents.set(agentName, { server: agentServer, port: agentPort, protocol: config.protocol }); return { success: true, name: agentName, port: agentPort }; } diff --git a/src/cli/operations/dev/web-ui/run-web-ui.ts b/src/cli/operations/dev/web-ui/run-web-ui.ts index bdf54bd03..2cce51eee 100644 --- a/src/cli/operations/dev/web-ui/run-web-ui.ts +++ b/src/cli/operations/dev/web-ui/run-web-ui.ts @@ -1,8 +1,8 @@ import { ExecLogger } from '../../../logging'; import { findAvailablePort } from '../server'; +import { openBrowser } from '../utils'; import { WEB_UI_DEFAULT_PORT } from './constants'; import { type WebUIOptions, WebUIServer } from './web-server'; -import { spawn } from 'child_process'; export interface RunWebUIOptions { /** Options to pass to WebUIServer (minus uiPort, which is resolved automatically) */ @@ -42,8 +42,7 @@ export async function runWebUI(opts: RunWebUIOptions): Promise { const chatUrl = url; console.log(`\nChat UI: ${chatUrl}`); console.log(`Press Ctrl+C to stop\n`); - const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; - spawn(openCmd, [chatUrl], { stdio: 'ignore', detached: true }).unref(); + openBrowser(chatUrl); }, onLog, }); diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index 8632fedb7..c3f9c6f36 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -32,16 +32,17 @@ const CSP_HEADER = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:"; /** Resolve the frontend dist directory. Returns null if not found. */ -function resolveUIDistDir(): string | null { +export function resolveUIDistDir(): string | null { const thisDir = path.dirname(fileURLToPath(import.meta.url)); const candidates = [ + process.env.AGENT_INSPECTOR_PATH, // Bundled CLI: dist/cli/index.mjs → dist/agent-inspector/ path.resolve(thisDir, '..', 'agent-inspector'), // npm package: @aws/agent-inspector/dist-assets/ path.resolve(thisDir, '..', '..', '..', '..', '..', 'node_modules', '@aws', 'agent-inspector', 'dist-assets'), // Dev via tsx: src/cli/operations/dev/web-ui/ → src/assets/agent-inspector/ path.resolve(thisDir, '..', '..', '..', '..', 'assets', 'agent-inspector'), - ]; + ].filter((c): c is string => !!c); for (const dir of candidates) { if (fs.existsSync(path.join(dir, 'index.html'))) return dir; } diff --git a/src/cli/operations/eval/__tests__/logs-eval.test.ts b/src/cli/operations/eval/__tests__/logs-eval.test.ts index e5b91ae61..5411d842e 100644 --- a/src/cli/operations/eval/__tests__/logs-eval.test.ts +++ b/src/cli/operations/eval/__tests__/logs-eval.test.ts @@ -89,7 +89,10 @@ describe('handleLogsEval', () => { ); }); - afterEach(() => vi.clearAllMocks()); + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); it('returns error when agent resolution fails', async () => { mockLoadDeployedProjectConfig.mockResolvedValue({}); @@ -156,7 +159,7 @@ describe('handleLogsEval', () => { mockStreamLogs.mockReturnValue(emptyGenerator()); // eslint-disable-next-line @typescript-eslint/no-empty-function - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); const result = await handleLogsEval({}); @@ -168,8 +171,6 @@ describe('handleLogsEval', () => { }) ); expect(mockSearchLogs).not.toHaveBeenCalled(); - - consoleSpy.mockRestore(); }); it('skips ResourceNotFoundException during search', async () => { @@ -282,6 +283,5 @@ describe('handleLogsEval', () => { await handleLogsEval({ since: '1h' }); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('IAM role does not exist')); - consoleSpy.mockRestore(); }); }); diff --git a/src/cli/operations/remove/__tests__/get-agent-scoped-credentials.test.ts b/src/cli/operations/remove/__tests__/get-agent-scoped-credentials.test.ts index a5d91a4c0..5f7f0e0dc 100644 --- a/src/cli/operations/remove/__tests__/get-agent-scoped-credentials.test.ts +++ b/src/cli/operations/remove/__tests__/get-agent-scoped-credentials.test.ts @@ -1,6 +1,6 @@ import type { Credential } from '../../../../schema/index.js'; import { AgentPrimitive } from '../../../primitives/AgentPrimitive.js'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; // Mock registry to break circular dependency: AgentPrimitive → AddFlow → hooks → registry → AgentPrimitive vi.mock('../../../primitives/registry', () => ({ @@ -12,6 +12,8 @@ const getAgentScopedCredentials = (...args: Parameters { + afterEach(() => vi.restoreAllMocks()); + const projectName = 'MyProject'; const makeCredential = (name: string): Credential => ({ name, authorizerType: 'ApiKeyCredentialProvider' }); diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index abfdaaf14..4702633ed 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -35,6 +35,7 @@ import { executeImportAgent } from '../operations/agent/import'; import { setupPythonProject } from '../operations/python'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; import { createRenderer } from '../templates'; +import { requireTTY } from '../tui/guards/tty'; import type { GenerateConfig, MemoryOption } from '../tui/screens/generate/types'; import { BasePrimitive } from './BasePrimitive'; import { CredentialPrimitive } from './CredentialPrimitive'; @@ -332,6 +333,7 @@ export class AgentPrimitive extends BasePrimitive e.name === evalName); - if (evaluator?.config.codeBased) { - throw new Error( - `Code-based evaluator "${evalName}" cannot be used in online eval configs. Only LLM-as-a-Judge evaluators are supported for online evaluation.` - ); - } - } - const config: OnlineEvalConfig = { name: options.name, agent: options.agent, diff --git a/src/cli/primitives/PolicyEnginePrimitive.ts b/src/cli/primitives/PolicyEnginePrimitive.ts index d8c6aaab2..a1f887547 100644 --- a/src/cli/primitives/PolicyEnginePrimitive.ts +++ b/src/cli/primitives/PolicyEnginePrimitive.ts @@ -3,6 +3,7 @@ import type { AgentCoreProjectSpec, PolicyEngine } from '../../schema'; import { PolicyEngineModeSchema, PolicyEngineSchema } from '../../schema'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; import { SOURCE_CODE_NOTE } from './constants'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; @@ -261,6 +262,7 @@ export class PolicyEnginePrimitive extends BasePrimitive { + readonly kind: ResourceType = 'runtime-endpoint'; + readonly label = 'Runtime Endpoint'; + readonly primitiveSchema = RuntimeEndpointSchema; + + async add(options: AddRuntimeEndpointOptions): Promise { + try { + const project = await this.readProjectSpec(); + + // Find the parent runtime + const runtime = project.runtimes.find(a => a.name === options.runtime); + if (!runtime) { + return { success: false, error: `Runtime "${options.runtime}" not found.` }; + } + + // Initialize endpoints dictionary if needed + runtime.endpoints ??= {}; + + // Check for duplicate endpoint name + if (runtime.endpoints[options.endpoint]) { + return { + success: false, + error: `Endpoint "${options.endpoint}" already exists on runtime "${options.runtime}".`, + }; + } + + // Validate version is a positive integer + const version = options.version ?? 1; + if (!Number.isInteger(version) || version < 1) { + return { success: false, error: `Version must be a positive integer (got ${version}).` }; + } + + // Check version against latest deployed version + try { + if (this.configIO.configExists('state')) { + const deployedState = await this.configIO.readDeployedState(); + for (const target of Object.values(deployedState.targets)) { + const deployedRuntime = target.resources?.runtimes?.[options.runtime]; + if (deployedRuntime?.runtimeVersion && version > deployedRuntime.runtimeVersion) { + return { + success: false, + error: `Version ${version} exceeds latest deployed version ${deployedRuntime.runtimeVersion} for runtime "${options.runtime}".`, + }; + } + } + } + } catch { + // Deployed state may not exist or be readable — skip version range check + } + + // Build and validate the endpoint config + const config = { + version, + ...(options.description ? { description: options.description } : {}), + }; + RuntimeEndpointSchema.parse(config); + + // Set the endpoint on the runtime + runtime.endpoints[options.endpoint] = config; + + // Write updated project spec + await this.writeProjectSpec(project); + + return { + success: true, + endpointName: options.endpoint, + agent: options.runtime, + version: config.version, + }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + async remove(name: string): Promise { + try { + const project = await this.readProjectSpec(); + + // Support composite key: runtimeName/endpointName + const slashIndex = name.indexOf('/'); + if (slashIndex > 0) { + const runtimeName = name.substring(0, slashIndex); + const endpointName = name.substring(slashIndex + 1); + const runtime = project.runtimes.find(r => r.name === runtimeName); + if (!runtime?.endpoints?.[endpointName]) { + return { success: false, error: `Runtime endpoint "${name}" not found.` }; + } + delete runtime.endpoints[endpointName]; + if (Object.keys(runtime.endpoints).length === 0) { + delete runtime.endpoints; + } + await this.writeProjectSpec(project); + return { success: true }; + } + + // Legacy: bare endpoint name — search all runtimes + for (const runtime of project.runtimes) { + if (runtime.endpoints?.[name]) { + delete runtime.endpoints[name]; + if (Object.keys(runtime.endpoints).length === 0) { + delete runtime.endpoints; + } + await this.writeProjectSpec(project); + return { success: true }; + } + } + + return { success: false, error: `Runtime endpoint "${name}" not found.` }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + async previewRemove(name: string): Promise { + const project = await this.readProjectSpec(); + + // Support composite key: runtimeName/endpointName + let runtimeName: string | undefined; + let endpointName: string = name; + let endpointConfig: { version: number; description?: string } | undefined; + + const slashIndex = name.indexOf('/'); + if (slashIndex > 0) { + runtimeName = name.substring(0, slashIndex); + endpointName = name.substring(slashIndex + 1); + const runtime = project.runtimes.find(r => r.name === runtimeName); + if (runtime?.endpoints?.[endpointName]) { + endpointConfig = runtime.endpoints[endpointName]; + } + } else { + // Legacy: bare endpoint name — search all runtimes + for (const runtime of project.runtimes) { + if (runtime.endpoints?.[name]) { + runtimeName = runtime.name; + endpointConfig = runtime.endpoints[name]; + break; + } + } + } + + if (!runtimeName || !endpointConfig) { + throw new Error(`Runtime endpoint "${name}" not found.`); + } + + const summary: string[] = []; + const schemaChanges: SchemaChange[] = []; + + summary.push(`Removing runtime endpoint: ${endpointName} (from runtime "${runtimeName}")`); + summary.push(` Version: ${endpointConfig.version}`); + if (endpointConfig.description) { + summary.push(` Description: ${endpointConfig.description}`); + } + + // Build after state + const afterProject = JSON.parse(JSON.stringify(project)) as AgentCoreProjectSpec; + const afterRuntime = afterProject.runtimes.find(a => a.name === runtimeName); + if (afterRuntime?.endpoints) { + delete afterRuntime.endpoints[endpointName]; + if (Object.keys(afterRuntime.endpoints).length === 0) { + delete afterRuntime.endpoints; + } + } + + schemaChanges.push({ + file: 'agentcore/agentcore.json', + before: project, + after: afterProject, + }); + + return { summary, directoriesToDelete: [], schemaChanges }; + } + + async getRemovable(): Promise { + try { + const project = await this.readProjectSpec(); + const removable: RemovableRuntimeEndpoint[] = []; + + for (const runtime of project.runtimes) { + if (!runtime.endpoints) continue; + + for (const [endpointName, endpointConfig] of Object.entries(runtime.endpoints)) { + removable.push({ + name: `${runtime.name}/${endpointName}`, + type: 'runtime-endpoint', + runtimeName: runtime.name, + endpointName, + version: endpointConfig.version, + description: endpointConfig.description, + }); + } + } + + return removable; + } catch { + return []; + } + } + + registerCommands(addCmd: Command, removeCmd: Command): void { + addCmd + .command('runtime-endpoint') + .description('Add a named endpoint (version alias) to a runtime') + .requiredOption('--runtime ', 'Runtime name to add the endpoint to') + .requiredOption('--endpoint ', 'Endpoint name (e.g., prod, staging)') + .option('--version ', 'Version number to alias (default: 1)', Number) + .option('--description ', 'Description of the endpoint') + .option('--json', 'Output as JSON [non-interactive]') + .action( + async (cliOptions: { + runtime: string; + endpoint: string; + version?: number; + description?: string; + json?: boolean; + }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + const result = await this.add({ + runtime: cliOptions.runtime, + endpoint: cliOptions.endpoint, + version: cliOptions.version, + description: cliOptions.description, + }); + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added runtime endpoint '${cliOptions.endpoint}' to runtime '${cliOptions.runtime}'`); + } else { + console.error(result.error); + } + + process.exit(result.success ? 0 : 1); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + } + ); + + removeCmd + .command('runtime-endpoint') + .description('Remove a runtime endpoint from the project') + .option('--name ', 'Name of resource to remove [non-interactive]') + .option('-y, --yes', 'Skip confirmation prompt [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .action(async (cliOptions: { name?: string; yes?: boolean; json?: boolean }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.name || cliOptions.yes || cliOptions.json) { + if (!cliOptions.name) { + console.log(JSON.stringify({ success: false, error: '--name is required' })); + process.exit(1); + } + + const result = await this.remove(cliOptions.name); + console.log( + JSON.stringify({ + success: result.success, + resourceType: this.kind, + resourceName: cliOptions.name, + message: result.success ? `Removed runtime endpoint '${cliOptions.name}'` : undefined, + note: result.success ? SOURCE_CODE_NOTE : undefined, + error: !result.success ? result.error : undefined, + }) + ); + process.exit(result.success ? 0 : 1); + } else { + const [{ render }, { default: React }, { RemoveFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/remove'), + ]); + const { clear, unmount } = render( + React.createElement(RemoveFlow, { + isInteractive: false, + force: cliOptions.yes, + initialResourceType: this.kind, + initialResourceName: cliOptions.name, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + }); + } + + addScreen(): AddScreenComponent { + return null; + } + + /** + * Stub for future cross-reference validation. + * Checks if any gateway targets reference a given runtime endpoint. + */ +} diff --git a/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts b/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts new file mode 100644 index 000000000..46fe426f5 --- /dev/null +++ b/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts @@ -0,0 +1,354 @@ +import { RuntimeEndpointPrimitive } from '../RuntimeEndpointPrimitive.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); +const mockConfigExists = vi.fn(); +const mockReadDeployedState = vi.fn(); + +vi.mock('../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + configExists = mockConfigExists; + readDeployedState = mockReadDeployedState; + }, + findConfigRoot: () => '/fake/root', +})); + +function makeProject( + runtimes: { + name: string; + endpoints?: Record; + }[] = [] +) { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: runtimes.map(r => ({ + name: r.name, + build: 'CodeZip' as const, + entrypoint: 'main.py' as any, + codeLocation: `app/${r.name}/` as any, + runtimeVersion: 'PYTHON_3_14' as any, + ...(r.endpoints && { endpoints: r.endpoints }), + })), + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + }; +} + +const primitive = new RuntimeEndpointPrimitive(); + +describe('RuntimeEndpointPrimitive', () => { + afterEach(() => vi.clearAllMocks()); + + it('has kind "runtime-endpoint"', () => { + expect(primitive.kind).toBe('runtime-endpoint'); + }); + + it('has label "Runtime Endpoint"', () => { + expect(primitive.label).toBe('Runtime Endpoint'); + }); + + describe('add', () => { + it('successfully adds endpoint to a runtime', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'prod', + version: 3, + description: 'Production endpoint', + }); + + expect(result.success).toBe(true); + + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + const runtime = writtenSpec.runtimes.find((r: any) => r.name === 'MyRuntime'); + expect(runtime.endpoints).toHaveProperty('prod'); + expect(runtime.endpoints.prod.version).toBe(3); + expect(runtime.endpoints.prod.description).toBe('Production endpoint'); + }); + + it('returns error when runtime not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'OtherRuntime' }])); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'NonExistent', + endpoint: 'prod', + }); + + expect(result).toEqual(expect.objectContaining({ success: false, error: expect.stringContaining('not found') })); + }); + + it('returns error when endpoint already exists', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime', endpoints: { prod: { version: 1 } } }])); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'prod', + }); + + expect(result).toEqual( + expect.objectContaining({ success: false, error: expect.stringContaining('already exists') }) + ); + }); + + it('defaults version to 1 when not provided', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'staging', + }); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.runtimes[0].endpoints.staging.version).toBe(1); + }); + + it.each([ + { version: 0, label: 'zero' }, + { version: -1, label: 'negative' }, + { version: 3.5, label: 'non-integer' }, + ])('returns error when version is $label ($version)', async ({ version }) => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'prod', + version, + }); + + expect(result).toEqual( + expect.objectContaining({ success: false, error: expect.stringContaining('positive integer') }) + ); + }); + + it('returns richer JSON response with endpointName, agent, and version', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'prod', + version: 2, + }); + + expect(result).toEqual( + expect.objectContaining({ + success: true, + endpointName: 'prod', + agent: 'MyRuntime', + version: 2, + }) + ); + }); + + it('returns error when version exceeds latest deployed version', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + mockConfigExists.mockReturnValue(true); + mockReadDeployedState.mockResolvedValue({ + targets: { + 'us-east-1': { + resources: { + runtimes: { + MyRuntime: { runtimeVersion: 3 }, + }, + }, + }, + }, + }); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'prod', + version: 5, + }); + + expect(result).toEqual( + expect.objectContaining({ success: false, error: expect.stringContaining('exceeds latest deployed version') }) + ); + }); + }); + + describe('remove', () => { + it('removes endpoint using composite key runtimeName/endpointName', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject([ + { + name: 'MyRuntime', + endpoints: { prod: { version: 1 }, staging: { version: 2 } }, + }, + ]) + ); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('MyRuntime/prod'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + const runtime = writtenSpec.runtimes[0]; + expect(runtime.endpoints).not.toHaveProperty('prod'); + expect(runtime.endpoints).toHaveProperty('staging'); + }); + + it('removes endpoint using legacy bare name (fallback)', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime', endpoints: { prod: { version: 1 } } }])); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('prod'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.runtimes[0].endpoints).toBeUndefined(); + }); + + it('returns error when endpoint not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + + const result = await primitive.remove('MyRuntime/nonexistent'); + + expect(result).toEqual(expect.objectContaining({ success: false, error: expect.stringContaining('not found') })); + }); + + it('cleans up empty endpoints dict after removing last endpoint', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime', endpoints: { prod: { version: 1 } } }])); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('MyRuntime/prod'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.runtimes[0].endpoints).toBeUndefined(); + }); + + it('correctly targets the right runtime when same endpoint name exists on multiple runtimes', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject([ + { name: 'RuntimeA', endpoints: { prod: { version: 1 } } }, + { name: 'RuntimeB', endpoints: { prod: { version: 2 } } }, + ]) + ); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('RuntimeB/prod'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + // RuntimeA should still have its prod endpoint + expect(writtenSpec.runtimes[0].endpoints).toHaveProperty('prod'); + // RuntimeB should have had its prod endpoint removed + expect(writtenSpec.runtimes[1].endpoints).toBeUndefined(); + }); + }); + + describe('previewRemove', () => { + it('returns summary with correct runtime and endpoint info using composite key', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject([ + { + name: 'MyRuntime', + endpoints: { prod: { version: 3, description: 'Production' } }, + }, + ]) + ); + + const preview = await primitive.previewRemove('MyRuntime/prod'); + + expect(preview.summary).toEqual( + expect.arrayContaining([expect.stringContaining('prod'), expect.stringContaining('MyRuntime')]) + ); + expect(preview.summary).toEqual(expect.arrayContaining([expect.stringContaining('Version: 3')])); + expect(preview.summary).toEqual(expect.arrayContaining([expect.stringContaining('Production')])); + }); + + it('returns schemaChanges showing before/after agentcore.json', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime', endpoints: { prod: { version: 1 } } }])); + + const preview = await primitive.previewRemove('MyRuntime/prod'); + + expect(preview.schemaChanges).toHaveLength(1); + expect(preview.schemaChanges[0]!.file).toBe('agentcore/agentcore.json'); + + // Before should have the endpoint + const before = preview.schemaChanges[0]!.before as any; + expect(before.runtimes[0].endpoints).toHaveProperty('prod'); + + // After should not have the endpoint (and endpoints dict cleaned up) + const after = preview.schemaChanges[0]!.after as any; + expect(after.runtimes[0].endpoints).toBeUndefined(); + }); + + it('throws when endpoint not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + + await expect(primitive.previewRemove('MyRuntime/missing')).rejects.toThrow('not found'); + }); + }); + + describe('getRemovable', () => { + it('returns all endpoints across all runtimes', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject([ + { name: 'RuntimeA', endpoints: { prod: { version: 1 }, staging: { version: 2 } } }, + { name: 'RuntimeB', endpoints: { beta: { version: 3 } } }, + ]) + ); + + const result = await primitive.getRemovable(); + + expect(result).toHaveLength(3); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'RuntimeA/prod', runtimeName: 'RuntimeA', endpointName: 'prod', version: 1 }), + expect.objectContaining({ + name: 'RuntimeA/staging', + runtimeName: 'RuntimeA', + endpointName: 'staging', + version: 2, + }), + expect.objectContaining({ name: 'RuntimeB/beta', runtimeName: 'RuntimeB', endpointName: 'beta', version: 3 }), + ]) + ); + }); + + it('uses composite key format runtimeName/endpointName for name field', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime', endpoints: { prod: { version: 1 } } }])); + + const result = await primitive.getRemovable(); + + expect(result[0]!.name).toBe('MyRuntime/prod'); + }); + + it('returns empty array when no endpoints exist', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + + const result = await primitive.getRemovable(); + + expect(result).toEqual([]); + }); + + it('returns empty array when no runtimes exist', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + const result = await primitive.getRemovable(); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/cli/primitives/index.ts b/src/cli/primitives/index.ts index 2ef948e57..2f19f9f3a 100644 --- a/src/cli/primitives/index.ts +++ b/src/cli/primitives/index.ts @@ -6,6 +6,8 @@ export { EvaluatorPrimitive } from './EvaluatorPrimitive'; export { OnlineEvalConfigPrimitive } from './OnlineEvalConfigPrimitive'; export { GatewayPrimitive } from './GatewayPrimitive'; export { GatewayTargetPrimitive } from './GatewayTargetPrimitive'; +export { RuntimeEndpointPrimitive } from './RuntimeEndpointPrimitive'; +export type { AddRuntimeEndpointOptions, RemovableRuntimeEndpoint } from './RuntimeEndpointPrimitive'; export { ALL_PRIMITIVES, agentPrimitive, @@ -15,6 +17,7 @@ export { onlineEvalConfigPrimitive, gatewayPrimitive, gatewayTargetPrimitive, + runtimeEndpointPrimitive, getPrimitive, } from './registry'; export { SOURCE_CODE_NOTE } from './constants'; diff --git a/src/cli/primitives/registry.ts b/src/cli/primitives/registry.ts index fd46a6be7..2680d1ea6 100644 --- a/src/cli/primitives/registry.ts +++ b/src/cli/primitives/registry.ts @@ -8,6 +8,7 @@ import { MemoryPrimitive } from './MemoryPrimitive'; import { OnlineEvalConfigPrimitive } from './OnlineEvalConfigPrimitive'; import { PolicyEnginePrimitive } from './PolicyEnginePrimitive'; import { PolicyPrimitive } from './PolicyPrimitive'; +import { RuntimeEndpointPrimitive } from './RuntimeEndpointPrimitive'; import type { RemovableResource } from './types'; /** @@ -22,6 +23,7 @@ export const gatewayPrimitive = new GatewayPrimitive(); export const gatewayTargetPrimitive = new GatewayTargetPrimitive(); export const policyEnginePrimitive = new PolicyEnginePrimitive(); export const policyPrimitive = new PolicyPrimitive(); +export const runtimeEndpointPrimitive = new RuntimeEndpointPrimitive(); /** * All primitives in display order. @@ -36,6 +38,7 @@ export const ALL_PRIMITIVES: BasePrimitive[] = [ gatewayTargetPrimitive, policyEnginePrimitive, policyPrimitive, + runtimeEndpointPrimitive, ]; /** diff --git a/src/cli/telemetry/__tests__/client.test.ts b/src/cli/telemetry/__tests__/client.test.ts new file mode 100644 index 000000000..e254524bf --- /dev/null +++ b/src/cli/telemetry/__tests__/client.test.ts @@ -0,0 +1,146 @@ +/* eslint-disable @typescript-eslint/require-await */ +import { CANCELLED, TelemetryClient } from '../client'; +import { InMemorySink } from '../sinks/in-memory-sink'; +import { describe, expect, it } from 'vitest'; + +describe('TelemetryClient', () => { + describe('withCommandRun', () => { + it('records success with returned attrs', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + await client.withCommandRun('update', async () => ({ check_only: true })); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs).toMatchObject({ + command_group: 'update', + command: 'update', + exit_reason: 'success', + check_only: 'true', + }); + }); + + it('accepts sync callbacks', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + await client.withCommandRun('telemetry.disable', () => ({})); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs).toMatchObject({ exit_reason: 'success' }); + }); + + it('records failure and re-throws on error', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + await expect( + client.withCommandRun('deploy', async () => { + throw new Error('boom'); + }) + ).rejects.toThrow('boom'); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs).toMatchObject({ + command_group: 'deploy', + exit_reason: 'failure', + error_name: 'UnknownError', + }); + }); + + it('classifies PackagingError subclasses', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + class MissingDependencyError extends Error { + constructor() { + super('missing dep'); + this.name = 'MissingDependencyError'; + } + } + + await expect( + client.withCommandRun('deploy', async () => { + throw new MissingDependencyError(); + }) + ).rejects.toThrow(); + + expect(sink.metrics[0]!.attrs).toMatchObject({ + error_name: 'PackagingError', + is_user_error: 'false', + }); + }); + + it('marks credential errors as user errors', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + class AwsCredentialsError extends Error { + constructor() { + super('creds expired'); + this.name = 'AwsCredentialsError'; + } + } + + await expect( + client.withCommandRun('invoke', async () => { + throw new AwsCredentialsError(); + }) + ).rejects.toThrow(); + + expect(sink.metrics[0]!.attrs).toMatchObject({ + error_name: 'CredentialsError', + is_user_error: 'true', + }); + }); + + it('records duration as a non-negative integer', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + await client.withCommandRun('telemetry.disable', async () => { + await new Promise(r => globalThis.setTimeout(r, 5)); + return {}; + }); + + expect(sink.metrics[0]!.value).toBeGreaterThanOrEqual(0); + expect(Number.isInteger(sink.metrics[0]!.value)).toBe(true); + }); + + it('converts boolean attrs to strings', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + await client.withCommandRun('update', async () => ({ check_only: true })); + + expect(sink.metrics[0]!.attrs.check_only).toBe('true'); + }); + + it('silently drops invalid success payloads', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + // Missing required attrs for 'create' — should silently drop + await client.withCommandRun( + 'create', + // @ts-expect-error — intentionally incomplete + async () => ({ language: 'python' }) + ); + + expect(sink.metrics).toHaveLength(0); + }); + + it('records cancel when callback returns CANCELLED', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + await client.withCommandRun('deploy', () => CANCELLED); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs).toMatchObject({ + command_group: 'deploy', + exit_reason: 'cancel', + }); + }); + }); +}); diff --git a/src/cli/telemetry/__tests__/composite-sink.test.ts b/src/cli/telemetry/__tests__/composite-sink.test.ts new file mode 100644 index 000000000..e07b2d65f --- /dev/null +++ b/src/cli/telemetry/__tests__/composite-sink.test.ts @@ -0,0 +1,60 @@ +import { InMemorySink } from '../sinks/in-memory-sink'; +import { CompositeSink, type MetricSink } from '../sinks/metric-sink'; +import { describe, expect, it, vi } from 'vitest'; + +describe('CompositeSink', () => { + it('fans out records to all sinks', () => { + const a = new InMemorySink(); + const b = new InMemorySink(); + const composite = new CompositeSink([a, b]); + + composite.record(100, { command: 'deploy' }); + + expect(a.metrics).toHaveLength(1); + expect(b.metrics).toHaveLength(1); + expect(a.metrics[0]!.attrs.command).toBe('deploy'); + }); + + it('isolates errors — one sink throwing does not affect others', () => { + const bad: MetricSink = { + record: vi.fn(() => { + throw new Error('sink failed'); + }), + flush: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const good = new InMemorySink(); + const composite = new CompositeSink([bad, good]); + + composite.record(100, { command: 'deploy' }); + + expect(good.metrics).toHaveLength(1); + }); + + it('flushes all sinks in parallel', async () => { + const a = new InMemorySink(); + const b = new InMemorySink(); + const flushA = vi.spyOn(a, 'flush'); + const flushB = vi.spyOn(b, 'flush'); + const composite = new CompositeSink([a, b]); + + await composite.flush(5000); + + expect(flushA).toHaveBeenCalledWith(5000); + expect(flushB).toHaveBeenCalledWith(5000); + }); + + it('flush settles even if one sink rejects', async () => { + const bad: MetricSink = { + record: vi.fn(), + flush: vi.fn().mockRejectedValue(new Error('flush failed')), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const good = new InMemorySink(); + const flushGood = vi.spyOn(good, 'flush'); + const composite = new CompositeSink([bad, good]); + + await expect(composite.flush()).resolves.toBeUndefined(); + expect(flushGood).toHaveBeenCalled(); + }); +}); diff --git a/src/cli/telemetry/__tests__/error-classification.test.ts b/src/cli/telemetry/__tests__/error-classification.test.ts new file mode 100644 index 000000000..0640e6ce0 --- /dev/null +++ b/src/cli/telemetry/__tests__/error-classification.test.ts @@ -0,0 +1,63 @@ +import { classifyError, isUserError } from '../error-classification'; +import { describe, expect, it } from 'vitest'; + +function errorWithName(name: string): Error { + const err = new Error('test'); + err.name = name; + return err; +} + +describe('classifyError', () => { + it.each([ + ['ConfigValidationError', 'ConfigError'], + ['ConfigNotFoundError', 'ConfigError'], + ['ConfigReadError', 'ConfigError'], + ['ConfigWriteError', 'ConfigError'], + ['ConfigParseError', 'ConfigError'], + ['AwsCredentialsError', 'CredentialsError'], + ['AccessDeniedException', 'CredentialsError'], + ['ExpiredToken', 'CredentialsError'], + ['PackagingError', 'PackagingError'], + ['MissingDependencyError', 'PackagingError'], + ['ArtifactSizeError', 'PackagingError'], + ['NoProjectError', 'ProjectError'], + ['AgentAlreadyExistsError', 'ProjectError'], + ['ResourceNotFoundException', 'ServiceError'], + ['ValidationException', 'ServiceError'], + ['ConflictException', 'ServiceError'], + ['ConnectionError', 'ConnectionError'], + ['ServerError', 'ConnectionError'], + ] as const)('%s → %s', (errorName, expected) => { + expect(classifyError(errorWithName(errorName))).toBe(expected); + }); + + it('returns UnknownError for unrecognized errors', () => { + expect(classifyError(new Error('something'))).toBe('UnknownError'); + }); + + it('returns UnknownError for non-Error values', () => { + expect(classifyError('string')).toBe('UnknownError'); + expect(classifyError(null)).toBe('UnknownError'); + expect(classifyError(undefined)).toBe('UnknownError'); + }); + + it('uses err.name when constructor.name is Error (SDK pattern)', () => { + // AWS SDK errors often: new Error(); err.name = 'ValidationException' + expect(classifyError(errorWithName('ValidationException'))).toBe('ServiceError'); + }); +}); + +describe('isUserError', () => { + it('returns true for user-fixable categories', () => { + expect(isUserError(errorWithName('ConfigValidationError'))).toBe(true); + expect(isUserError(errorWithName('AwsCredentialsError'))).toBe(true); + expect(isUserError(errorWithName('NoProjectError'))).toBe(true); + }); + + it('returns false for system categories', () => { + expect(isUserError(errorWithName('PackagingError'))).toBe(false); + expect(isUserError(errorWithName('ResourceNotFoundException'))).toBe(false); + expect(isUserError(errorWithName('ConnectionError'))).toBe(false); + expect(isUserError(new Error('unknown'))).toBe(false); + }); +}); diff --git a/src/cli/telemetry/__tests__/resolve.test.ts b/src/cli/telemetry/__tests__/resolve.test.ts index 56a102692..bdb326f42 100644 --- a/src/cli/telemetry/__tests__/resolve.test.ts +++ b/src/cli/telemetry/__tests__/resolve.test.ts @@ -1,5 +1,5 @@ import { createTempConfig } from '../../__tests__/helpers/temp-config'; -import { resolveTelemetryPreference } from '../resolve'; +import { resolveTelemetryPreference } from '../config'; import { writeFile } from 'fs/promises'; import { join } from 'node:path'; import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest'; diff --git a/src/cli/telemetry/__tests__/resource-resolver.test.ts b/src/cli/telemetry/__tests__/resource-resolver.test.ts new file mode 100644 index 000000000..47c9c4749 --- /dev/null +++ b/src/cli/telemetry/__tests__/resource-resolver.test.ts @@ -0,0 +1,50 @@ +import { resolveResourceAttributes } from '../config'; +import { ResourceAttributesSchema } from '../schemas/common-attributes'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const ORIGINAL_ENV = process.env.AGENTCORE_CONFIG_DIR; + +describe('resolveResourceAttributes', () => { + beforeEach(() => { + process.env.AGENTCORE_CONFIG_DIR = '/tmp/telemetry-test-' + Date.now(); + }); + + afterEach(() => { + if (ORIGINAL_ENV === undefined) { + delete process.env.AGENTCORE_CONFIG_DIR; + } else { + process.env.AGENTCORE_CONFIG_DIR = ORIGINAL_ENV; + } + }); + + it('returns attributes that pass schema validation', async () => { + const attrs = await resolveResourceAttributes('cli'); + expect(() => ResourceAttributesSchema.parse(attrs)).not.toThrow(); + }); + + it('sets service.name to agentcore-cli', async () => { + const attrs = await resolveResourceAttributes('cli'); + expect(attrs['service.name']).toBe('agentcore-cli'); + }); + + it('generates unique session_id per call', async () => { + const a = await resolveResourceAttributes('cli'); + const b = await resolveResourceAttributes('cli'); + expect(a['agentcore-cli.session_id']).not.toBe(b['agentcore-cli.session_id']); + }); + + it('reflects the mode parameter', async () => { + const cli = await resolveResourceAttributes('cli'); + const tui = await resolveResourceAttributes('tui'); + expect(cli['agentcore-cli.mode']).toBe('cli'); + expect(tui['agentcore-cli.mode']).toBe('tui'); + }); + + it('populates os and node fields', async () => { + const attrs = await resolveResourceAttributes('cli'); + expect(attrs['os.type']).toBeTruthy(); + expect(attrs['os.version']).toBeTruthy(); + expect(attrs['host.arch']).toBeTruthy(); + expect(attrs['node.version']).toMatch(/^v\d+/); + }); +}); diff --git a/src/cli/telemetry/client.ts b/src/cli/telemetry/client.ts new file mode 100644 index 000000000..3228f45b1 --- /dev/null +++ b/src/cli/telemetry/client.ts @@ -0,0 +1,97 @@ +import { classifyError, isUserError } from './error-classification.js'; +import { COMMAND_SCHEMAS, type Command, type CommandAttrs, deriveCommandGroup } from './schemas/command-run.js'; +import { type CommandResult, CommandResultSchema } from './schemas/common-shapes.js'; +import type { MetricSink } from './sinks/metric-sink.js'; +import { performance } from 'perf_hooks'; + +/** Return this from the withCommandRun callback to record a cancellation. */ +export const CANCELLED = Symbol('cancelled'); + +export class TelemetryClient { + constructor(private readonly sink: MetricSink) {} + + /** + * Wrap a command action with telemetry recording. + * + * Return attrs on success, or CANCELLED on user cancellation. + * Unhandled throws are classified as failures and re-thrown. + * + * ```ts + * await client.withCommandRun('deploy', async () => { + * if (userCancelled) return CANCELLED; + * const result = await runDeploy(options); + * return { runtime_count: result.runtimes.length, ... }; + * }); + * ``` + */ + async withCommandRun( + command: C, + fn: () => CommandAttrs | typeof CANCELLED | Promise | typeof CANCELLED> + ): Promise { + const start = performance.now(); + try { + const result = await fn(); + const durationMs = Math.round(performance.now() - start); + if (result === CANCELLED) { + this.recordCommandRun(command, { exit_reason: 'cancel' }, {}, durationMs); + } else { + this.recordCommandRun(command, { exit_reason: 'success' }, result, durationMs); + } + } catch (err) { + const failureResult: CommandResult & { exit_reason: 'failure' } = { + exit_reason: 'failure', + error_name: classifyError(err), + is_user_error: isUserError(err), + }; + this.recordCommandRun(command, failureResult, {}, Math.round(performance.now() - start)); + throw err; + } finally { + try { + await this.sink.flush(); + } catch { + /* telemetry must not mask command errors */ + } + } + } + + async shutdown(): Promise { + try { + await this.sink.shutdown(); + } catch { + /* telemetry must not affect CLI behavior */ + } + } + + private recordCommandRun( + command: C, + result: CommandResult, + attrs: CommandAttrs | Partial>, + durationMs: number + ): void { + try { + CommandResultSchema.parse(result); + if (result.exit_reason !== 'failure' && result.exit_reason !== 'cancel') { + COMMAND_SCHEMAS[command].parse(attrs); + } + + const otelAttrs: Record = { + command_group: deriveCommandGroup(command), + command, + }; + + for (const obj of [result, attrs]) { + for (const [k, v] of Object.entries(obj)) { + if (typeof v === 'boolean') { + otelAttrs[k] = String(v); + } else if (typeof v === 'string' || typeof v === 'number') { + otelAttrs[k] = v; + } + } + } + + this.sink.record(durationMs, otelAttrs); + } catch { + // Telemetry must never affect CLI behavior + } + } +} diff --git a/src/cli/telemetry/config.ts b/src/cli/telemetry/config.ts new file mode 100644 index 000000000..5bee94eff --- /dev/null +++ b/src/cli/telemetry/config.ts @@ -0,0 +1,61 @@ +import { PACKAGE_VERSION } from '../constants.js'; +import { getOrCreateInstallationId, readGlobalConfig } from '../global-config.js'; +import { type ResourceAttributes, ResourceAttributesSchema } from './schemas/common-attributes.js'; +import { randomUUID } from 'crypto'; +import os from 'os'; + +// --------------------------------------------------------------------------- +// Telemetry preference (opt-in / opt-out) +// --------------------------------------------------------------------------- + +export interface TelemetryPreference { + enabled: boolean; + source: 'environment' | 'global-config' | 'default'; + envVar?: { name: string; value: string }; +} + +const ENV_VAR_NAME = 'AGENTCORE_TELEMETRY_DISABLED'; + +export async function resolveTelemetryPreference(configFile?: string): Promise { + const agentcoreEnv = process.env[ENV_VAR_NAME]; + if (agentcoreEnv !== undefined) { + const normalized = agentcoreEnv.toLowerCase().trim(); + if (normalized === 'false' || normalized === '0') { + return { enabled: true, source: 'environment', envVar: { name: ENV_VAR_NAME, value: agentcoreEnv } }; + } + if (normalized !== '') { + return { enabled: false, source: 'environment', envVar: { name: ENV_VAR_NAME, value: agentcoreEnv } }; + } + } + + const config = await readGlobalConfig(configFile); + if (typeof config.telemetry?.enabled === 'boolean') { + return { enabled: config.telemetry.enabled, source: 'global-config' }; + } + + return { enabled: true, source: 'default' }; +} + +// --------------------------------------------------------------------------- +// Resource attributes (per-session OTel metadata) +// --------------------------------------------------------------------------- + +/** + * Resolve and validate resource attributes for the current session. + * Called once at startup — the returned object is reused for every metric in the session. + * Throws if any attribute fails validation (prevents PII leakage). + */ +export async function resolveResourceAttributes(mode: 'cli' | 'tui'): Promise { + const { id } = await getOrCreateInstallationId(); + return ResourceAttributesSchema.parse({ + 'service.name': 'agentcore-cli', + 'service.version': PACKAGE_VERSION, + 'agentcore-cli.installation_id': id, + 'agentcore-cli.session_id': randomUUID(), + 'agentcore-cli.mode': mode, + 'os.type': os.type(), + 'os.version': os.release(), + 'host.arch': os.arch(), + 'node.version': process.version, + }); +} diff --git a/src/cli/telemetry/error-classification.ts b/src/cli/telemetry/error-classification.ts new file mode 100644 index 000000000..0cc5d060d --- /dev/null +++ b/src/cli/telemetry/error-classification.ts @@ -0,0 +1,62 @@ +import { type ErrorCategory } from './schemas/common-shapes.js'; +import type { z } from 'zod'; + +type ErrorCategoryValue = z.infer; + +const CONFIG_ERRORS = new Set([ + 'ConfigValidationError', + 'ConfigNotFoundError', + 'ConfigReadError', + 'ConfigWriteError', + 'ConfigParseError', +]); +const PACKAGING_ERRORS = new Set([ + 'PackagingError', + 'MissingDependencyError', + 'MissingProjectFileError', + 'UnsupportedLanguageError', + 'ArtifactSizeError', +]); +const CREDENTIAL_ERRORS = new Set([ + 'AwsCredentialsError', + 'AccessDeniedException', + 'AccessDenied', + 'ExpiredToken', + 'ExpiredTokenException', + 'TokenRefreshRequired', + 'CredentialsExpired', + 'InvalidIdentityToken', + 'UnauthorizedAccess', + 'InvalidClientTokenId', +]); +const PROJECT_ERRORS = new Set(['NoProjectError', 'AgentAlreadyExistsError']); +const CONNECTION_ERRORS = new Set(['ConnectionError', 'ServerError']); +const SERVICE_ERRORS = new Set([ + 'ResourceNotFoundException', + 'ValidationException', + 'ConflictException', + 'ResourceAlreadyExistsException', +]); + +const USER_CATEGORIES = new Set(['ConfigError', 'CredentialsError', 'ProjectError']); + +export function classifyError(err: unknown): ErrorCategoryValue { + if (!(err instanceof Error)) return 'UnknownError'; + const name = + err.constructor.name === 'Error' + ? 'name' in err && typeof err.name === 'string' + ? err.name + : 'Error' + : err.constructor.name; + if (CONFIG_ERRORS.has(name)) return 'ConfigError'; + if (CREDENTIAL_ERRORS.has(name)) return 'CredentialsError'; + if (PACKAGING_ERRORS.has(name)) return 'PackagingError'; + if (PROJECT_ERRORS.has(name)) return 'ProjectError'; + if (SERVICE_ERRORS.has(name)) return 'ServiceError'; + if (CONNECTION_ERRORS.has(name)) return 'ConnectionError'; + return 'UnknownError'; +} + +export function isUserError(err: unknown): boolean { + return USER_CATEGORIES.has(classifyError(err)); +} diff --git a/src/cli/telemetry/index.ts b/src/cli/telemetry/index.ts index 2a12c518c..4686ae7b6 100644 --- a/src/cli/telemetry/index.ts +++ b/src/cli/telemetry/index.ts @@ -1,2 +1,6 @@ -export { resolveTelemetryPreference } from './resolve.js'; -export type { TelemetryPreference } from './resolve.js'; +export { resolveTelemetryPreference, resolveResourceAttributes } from './config.js'; +export type { TelemetryPreference } from './config.js'; +export { TelemetryClient, CANCELLED } from './client.js'; +export { type MetricSink, CompositeSink } from './sinks/metric-sink.js'; +export { OtelMetricSink, type OtelMetricSinkConfig } from './sinks/otel-metric-sink.js'; +export { classifyError, isUserError } from './error-classification.js'; diff --git a/src/cli/telemetry/resolve.ts b/src/cli/telemetry/resolve.ts deleted file mode 100644 index e009b59ce..000000000 --- a/src/cli/telemetry/resolve.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { readGlobalConfig } from '../global-config.js'; - -export interface TelemetryPreference { - enabled: boolean; - source: 'environment' | 'global-config' | 'default'; - envVar?: { name: string; value: string }; -} - -const ENV_VAR_NAME = 'AGENTCORE_TELEMETRY_DISABLED'; - -export async function resolveTelemetryPreference(configFile?: string): Promise { - const agentcoreEnv = process.env[ENV_VAR_NAME]; - if (agentcoreEnv !== undefined) { - const normalized = agentcoreEnv.toLowerCase().trim(); - if (normalized === 'false' || normalized === '0') { - return { enabled: true, source: 'environment', envVar: { name: ENV_VAR_NAME, value: agentcoreEnv } }; - } - if (normalized !== '') { - return { enabled: false, source: 'environment', envVar: { name: ENV_VAR_NAME, value: agentcoreEnv } }; - } - } - - const config = await readGlobalConfig(configFile); - if (typeof config.telemetry?.enabled === 'boolean') { - return { enabled: config.telemetry.enabled, source: 'global-config' }; - } - - return { enabled: true, source: 'default' }; -} diff --git a/src/cli/telemetry/schemas/__tests__/command-run.test.ts b/src/cli/telemetry/schemas/__tests__/command-run.test.ts new file mode 100644 index 000000000..11d293c71 --- /dev/null +++ b/src/cli/telemetry/schemas/__tests__/command-run.test.ts @@ -0,0 +1,172 @@ +import { COMMAND_SCHEMAS, type Command, type CommandAttrs, deriveCommandGroup } from '../command-run'; +import { ResourceAttributesSchema } from '../common-attributes'; +import { CommandResultSchema } from '../common-shapes'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { z } from 'zod'; + +describe('CommandResultSchema', () => { + it('accepts success with no error fields', () => { + expect(CommandResultSchema.parse({ exit_reason: 'success' })).toEqual({ exit_reason: 'success' }); + }); + + it('accepts failure with required error fields', () => { + const result = CommandResultSchema.parse({ + exit_reason: 'failure', + error_name: 'PackagingError', + is_user_error: false, + }); + expect(result).toMatchObject({ exit_reason: 'failure', error_name: 'PackagingError' }); + }); + + it('rejects failure missing error_name', () => { + expect(() => CommandResultSchema.parse({ exit_reason: 'failure' })).toThrow(); + }); + + it('rejects invalid exit_reason', () => { + expect(() => CommandResultSchema.parse({ exit_reason: 'timeout' })).toThrow(); + }); +}); + +describe('COMMAND_SCHEMAS', () => { + it('every command key produces a valid command_group', () => { + for (const command of Object.keys(COMMAND_SCHEMAS) as Command[]) { + const group = deriveCommandGroup(command); + expect(group).toBeTruthy(); + expect(group).not.toContain('.'); + } + }); + + it('accepts valid deploy attrs', () => { + const attrs = { + runtime_count: 2, + memory_count: 1, + credential_count: 0, + evaluator_count: 0, + online_eval_count: 0, + gateway_count: 1, + gateway_target_count: 3, + policy_engine_count: 0, + policy_count: 0, + has_diff: true, + }; + expect(COMMAND_SCHEMAS.deploy.parse(attrs)).toEqual(attrs); + }); + + it('rejects deploy attrs with negative count', () => { + expect(() => + COMMAND_SCHEMAS.deploy.parse({ + runtime_count: -1, + memory_count: 0, + credential_count: 0, + evaluator_count: 0, + online_eval_count: 0, + gateway_count: 0, + gateway_target_count: 0, + policy_engine_count: 0, + policy_count: 0, + has_diff: false, + }) + ).toThrow(); + }); + + it('rejects deploy attrs with float count', () => { + expect(() => + COMMAND_SCHEMAS.deploy.parse({ + runtime_count: 1.5, + memory_count: 0, + credential_count: 0, + evaluator_count: 0, + online_eval_count: 0, + gateway_count: 0, + gateway_target_count: 0, + policy_engine_count: 0, + policy_count: 0, + has_diff: false, + }) + ).toThrow(); + }); + + it('accepts valid create attrs', () => { + const attrs = { + language: 'python', + framework: 'strands', + model_provider: 'bedrock', + memory: 'shortterm', + protocol: 'mcp', + build: 'codezip', + agent_type: 'create', + network_mode: 'public', + has_agent: true, + }; + expect(COMMAND_SCHEMAS.create.parse(attrs)).toEqual(attrs); + }); + + it('rejects create attrs with invalid enum value', () => { + expect(() => + COMMAND_SCHEMAS.create.parse({ + language: 'rust', + framework: 'strands', + model_provider: 'bedrock', + memory: 'shortterm', + protocol: 'mcp', + build: 'codezip', + agent_type: 'create', + network_mode: 'public', + has_agent: true, + }) + ).toThrow(); + }); + + it('no-attrs commands accept empty object', () => { + expect(COMMAND_SCHEMAS['telemetry.disable'].parse({})).toEqual({}); + }); +}); + +describe('deriveCommandGroup', () => { + it.each([ + ['create', 'create'], + ['add.agent', 'add'], + ['logs.evals', 'logs'], + ['remove.gateway-target', 'remove'], + ['telemetry.disable', 'telemetry'], + ] as const)('%s → %s', (command, expected) => { + expect(deriveCommandGroup(command)).toBe(expected); + }); +}); + +describe('type safety', () => { + it('CommandAttrs requires runtime_count', () => { + expectTypeOf>().toHaveProperty('runtime_count'); + }); + + it('CommandAttrs requires language', () => { + expectTypeOf>().toHaveProperty('language'); + }); + + it('CommandAttrs is empty', () => { + expectTypeOf>().toEqualTypeOf>(); + }); + + it('no command schema contains arbitrary string fields', () => { + for (const [cmd, schema] of Object.entries(COMMAND_SCHEMAS)) { + for (const [field, zodType] of Object.entries(schema.shape)) { + const safe = + zodType instanceof z.ZodEnum || + zodType instanceof z.ZodBoolean || + zodType instanceof z.ZodNumber || + zodType instanceof z.ZodLiteral; + expect(safe, `${cmd}.${field} is an unsafe type`).toBe(true); + } + } + }); + + it('no resource attribute allows unbounded strings', () => { + for (const field of Object.keys(ResourceAttributesSchema.shape)) { + const partial = ResourceAttributesSchema.partial(); + const freeText = partial.safeParse({ [field]: 'UNCONSTRAINED_FREE_TEXT_VALUE_THAT_SHOULD_FAIL' }); + const empty = partial.safeParse({ [field]: '' }); + const isConstrained = !freeText.success || !empty.success; + expect(isConstrained, `${field} accepts arbitrary strings`).toBe(true); + } + }); +}); diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts new file mode 100644 index 000000000..f8a6df436 --- /dev/null +++ b/src/cli/telemetry/schemas/command-run.ts @@ -0,0 +1,221 @@ +import { + Action, + AgentType, + AttachMode, + AuthType, + AuthorizerType, + Build, + Count, + CredentialType, + EvaluatorType, + FilterState, + FilterType, + Framework, + GatewayTargetHost, + GatewayTargetType, + Language, + Level, + Memory, + ModelProvider, + NetworkMode, + OutboundAuth, + PolicyEngineMode, + Protocol, + RefType, + ResourceType, + SourceType, + ValidationMode, + safeSchema, +} from './common-shapes.js'; +import { z } from 'zod'; + +// --------------------------------------------------------------------------- +// Per-command attribute schemas +// All schemas use safeSchema() which rejects z.string() at compile time. +// --------------------------------------------------------------------------- + +const CreateAttrs = safeSchema({ + language: Language, + framework: Framework, + model_provider: ModelProvider, + memory: Memory, + protocol: Protocol, + build: Build, + agent_type: z.enum(['create', 'import']), + network_mode: NetworkMode, + has_agent: z.boolean(), +}); + +const AddAgentAttrs = safeSchema({ + language: Language, + framework: Framework, + model_provider: ModelProvider, + agent_type: AgentType, + build: Build, + protocol: Protocol, + network_mode: NetworkMode, + authorizer_type: AuthorizerType, + memory: Memory, +}); + +const AddMemoryAttrs = safeSchema({ + strategy_count: Count, + strategy_semantic: z.boolean(), + strategy_summarization: z.boolean(), + strategy_user_preference: z.boolean(), + strategy_episodic: z.boolean(), +}); + +const AddCredentialAttrs = safeSchema({ credential_type: CredentialType }); + +const AddEvaluatorAttrs = safeSchema({ evaluator_type: EvaluatorType, level: Level }); + +const AddOnlineEvalAttrs = safeSchema({ evaluator_count: Count, enable_on_create: z.boolean() }); + +const AddGatewayAttrs = safeSchema({ + authorizer_type: AuthorizerType, + has_policy_engine: z.boolean(), + policy_engine_mode: PolicyEngineMode, + semantic_search: z.boolean(), + runtime_count: Count, +}); + +const AddGatewayTargetAttrs = safeSchema({ + target_type: GatewayTargetType, + host: GatewayTargetHost, + outbound_auth: OutboundAuth, +}); + +const AddPolicyEngineAttrs = safeSchema({ attach_gateway_count: Count, attach_mode: AttachMode }); + +const AddPolicyAttrs = safeSchema({ source_type: SourceType, validation_mode: ValidationMode }); + +const DeployAttrs = safeSchema({ + runtime_count: Count, + memory_count: Count, + credential_count: Count, + evaluator_count: Count, + online_eval_count: Count, + gateway_count: Count, + gateway_target_count: Count, + policy_engine_count: Count, + policy_count: Count, + has_diff: z.boolean(), +}); + +const DevAttrs = safeSchema({ + action: Action, + has_stream: z.boolean(), + protocol: Protocol, + invoke_count: Count, +}); + +const InvokeAttrs = safeSchema({ + has_stream: z.boolean(), + has_session_id: z.boolean(), + auth_type: AuthType, + protocol: Protocol, +}); + +const StatusAttrs = safeSchema({ filter_type: FilterType, filter_state: FilterState }); + +const LogsAttrs = safeSchema({ has_query: z.boolean(), has_level_filter: z.boolean() }); + +const LogsEvalsAttrs = safeSchema({ has_follow: z.boolean() }); + +const RunEvalAttrs = safeSchema({ + evaluator_count: Count, + ref_type: RefType, + has_assertions: z.boolean(), + has_expected_trajectory: z.boolean(), + has_expected_response: z.boolean(), +}); + +const FetchAccessAttrs = safeSchema({ resource_type: ResourceType }); + +const UpdateAttrs = safeSchema({ check_only: z.boolean() }); + +const PauseResumeOnlineEvalAttrs = safeSchema({ ref_type: RefType }); + +const NoAttrs = safeSchema({}); + +// --------------------------------------------------------------------------- +// Command schema registry — single source of truth +// --------------------------------------------------------------------------- + +export const COMMAND_SCHEMAS = { + // create + create: CreateAttrs, + + // add + 'add.agent': AddAgentAttrs, + 'add.memory': AddMemoryAttrs, + 'add.credential': AddCredentialAttrs, + 'add.evaluator': AddEvaluatorAttrs, + 'add.online-eval': AddOnlineEvalAttrs, + 'add.gateway': AddGatewayAttrs, + 'add.gateway-target': AddGatewayTargetAttrs, + 'add.policy-engine': AddPolicyEngineAttrs, + 'add.policy': AddPolicyAttrs, + + // deploy + deploy: DeployAttrs, + + // dev / invoke + dev: DevAttrs, + invoke: InvokeAttrs, + + // status / logs + status: StatusAttrs, + logs: LogsAttrs, + 'logs.evals': LogsEvalsAttrs, + + // run + 'run.eval': RunEvalAttrs, + + // fetch + 'fetch.access': FetchAccessAttrs, + + // update + update: UpdateAttrs, + + // pause / resume + 'pause.online-eval': PauseResumeOnlineEvalAttrs, + 'resume.online-eval': PauseResumeOnlineEvalAttrs, + + // no command-specific attributes + 'traces.list': NoAttrs, + 'traces.get': NoAttrs, + 'evals.history': NoAttrs, + import: NoAttrs, + 'import.runtime': NoAttrs, + 'import.memory': NoAttrs, + package: NoAttrs, + validate: NoAttrs, + 'help.modes': NoAttrs, + 'remove.agent': NoAttrs, + 'remove.memory': NoAttrs, + 'remove.credential': NoAttrs, + 'remove.evaluator': NoAttrs, + 'remove.online-eval': NoAttrs, + 'remove.gateway': NoAttrs, + 'remove.gateway-target': NoAttrs, + 'remove.policy-engine': NoAttrs, + 'remove.policy': NoAttrs, + 'telemetry.disable': NoAttrs, + 'telemetry.enable': NoAttrs, + 'telemetry.status': NoAttrs, +} as const satisfies Record>; + +// --------------------------------------------------------------------------- +// Derived types +// --------------------------------------------------------------------------- + +export type Command = keyof typeof COMMAND_SCHEMAS; +export type CommandAttrs = z.infer<(typeof COMMAND_SCHEMAS)[C]>; + +/** Derive command_group from command key (e.g. 'add.agent' → 'add') */ +export function deriveCommandGroup(command: Command): string { + const dot = command.indexOf('.'); + return dot === -1 ? command : command.slice(0, dot); +} diff --git a/src/cli/telemetry/schemas/common-attributes.ts b/src/cli/telemetry/schemas/common-attributes.ts new file mode 100644 index 000000000..3085db7cc --- /dev/null +++ b/src/cli/telemetry/schemas/common-attributes.ts @@ -0,0 +1,30 @@ +import { Mode } from './common-shapes.js'; +import { z } from 'zod'; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SEMVER_PATTERN = /^\d+\.\d+\.\d+/; +const NODE_VERSION_PATTERN = /^v\d+\.\d+\.\d+$/; +const MAX_ATTR_LENGTH = 64; + +/** + * Resource attributes attached to every metric datapoint. + * Set once per session, not per-event. + * + * Constraints are intentionally strict to prevent PII leakage: + * - IDs must be UUID format (no user-chosen strings) + * - Version strings are pattern-constrained + * - All free-text fields are length-bounded + */ +export const ResourceAttributesSchema = z.object({ + 'service.name': z.literal('agentcore-cli'), + 'service.version': z.string().regex(SEMVER_PATTERN), + 'agentcore-cli.installation_id': z.string().regex(UUID_PATTERN), + 'agentcore-cli.session_id': z.string().regex(UUID_PATTERN), + 'agentcore-cli.mode': Mode, + 'os.type': z.string().min(1).max(MAX_ATTR_LENGTH), + 'os.version': z.string().min(1).max(MAX_ATTR_LENGTH), + 'host.arch': z.string().min(1).max(MAX_ATTR_LENGTH), + 'node.version': z.string().regex(NODE_VERSION_PATTERN), +}); + +export type ResourceAttributes = z.infer; diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts new file mode 100644 index 000000000..5c5e56493 --- /dev/null +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; + +// Type-safe schema builder: rejects z.string() at compile time. +// Only z.enum(), z.boolean(), z.number(), and z.literal() are allowed as field types. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SafeField = z.ZodEnum | z.ZodBoolean | z.ZodNumber | z.ZodLiteral; +export function safeSchema>(shape: T) { + return z.object(shape); +} + +// Primitive types +export const Count = z.number().int().nonnegative(); + +// Shared enums — alphabetical, one per attribute name from the metric shape spec +export const Action = z.enum(['server', 'invoke']); +export const AgentType = z.enum(['create', 'byo', 'import']); +export const AttachMode = z.enum(['log_only', 'enforce']); +export const AuthType = z.enum(['sigv4', 'bearer_token']); +export const AuthorizerType = z.enum(['aws_iam', 'custom_jwt', 'none']); +export const Build = z.enum(['codezip', 'container']); +export const CredentialType = z.enum(['api-key', 'oauth']); +export const EvaluatorType = z.enum(['llm-as-a-judge', 'code-based']); +export const ExitReason = z.enum(['success', 'failure', 'cancel']); +export const FilterState = z.enum(['deployed', 'local-only', 'pending-removal', 'none']); +export const FilterType = z.enum([ + 'agent', + 'memory', + 'credential', + 'gateway', + 'evaluator', + 'online-eval', + 'policy-engine', + 'policy', + 'none', +]); +export const Framework = z.enum(['strands', 'langchain_langgraph', 'googleadk', 'openaiagents']); +export const GatewayTargetHost = z.enum(['lambda', 'agentcoreruntime']); +export const GatewayTargetType = z.enum([ + 'mcp-server', + 'api-gateway', + 'open-api-schema', + 'smithy-model', + 'lambda-function-arn', +]); +export const Language = z.enum(['python', 'typescript', 'other']); +export const Level = z.enum(['session', 'trace', 'tool_call']); +export const Memory = z.enum(['none', 'shortterm', 'longandshortterm']); +export const Mode = z.enum(['cli', 'tui']); +export const ModelProvider = z.enum(['bedrock', 'anthropic', 'openai', 'gemini']); +export const NetworkMode = z.enum(['public', 'vpc']); +export const OutboundAuth = z.enum(['oauth', 'api-key', 'none']); +export const PolicyEngineMode = z.enum(['log_only', 'enforce']); +export const Protocol = z.enum(['http', 'mcp', 'a2a']); +export const RefType = z.enum(['arn', 'name']); +export const ResourceType = z.enum(['gateway', 'agent']); +export const SourceType = z.enum(['file', 'statement', 'generate']); +export const ValidationMode = z.enum(['fail_on_any_findings', 'ignore_all_findings']); + +export const ErrorCategory = z.enum([ + 'ConfigError', + 'CredentialsError', + 'PackagingError', + 'ProjectError', + 'ServiceError', + 'ConnectionError', + 'UnknownError', +]); + +// Common result shapes — reusable across metrics +export const SuccessResult = z.object({ exit_reason: z.literal('success') }); +export const CancelResult = z.object({ exit_reason: z.literal('cancel') }); +export const FailureResult = z.object({ + exit_reason: z.literal('failure'), + error_name: ErrorCategory, + is_user_error: z.boolean(), +}); +export const CommandResultSchema = z.discriminatedUnion('exit_reason', [SuccessResult, CancelResult, FailureResult]); +export type CommandResult = z.infer; diff --git a/src/cli/telemetry/schemas/index.ts b/src/cli/telemetry/schemas/index.ts new file mode 100644 index 000000000..7110f61d9 --- /dev/null +++ b/src/cli/telemetry/schemas/index.ts @@ -0,0 +1,13 @@ +export { + CommandResultSchema, + Count, + ErrorCategory, + ExitReason, + FailureResult, + Mode, + SuccessResult, + CancelResult, + type CommandResult, +} from './common-shapes.js'; +export { ResourceAttributesSchema, type ResourceAttributes } from './common-attributes.js'; +export { COMMAND_SCHEMAS, deriveCommandGroup, type Command, type CommandAttrs } from './command-run.js'; diff --git a/src/cli/telemetry/sinks/in-memory-sink.ts b/src/cli/telemetry/sinks/in-memory-sink.ts new file mode 100644 index 000000000..aab23680c --- /dev/null +++ b/src/cli/telemetry/sinks/in-memory-sink.ts @@ -0,0 +1,19 @@ +import type { MetricSink } from './metric-sink.js'; + +export interface RecordedMetric { + value: number; + attrs: Record; +} + +export class InMemorySink implements MetricSink { + readonly metrics: RecordedMetric[] = []; + + record(value: number, attrs: Record): void { + this.metrics.push({ value, attrs }); + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + async flush(): Promise {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + async shutdown(): Promise {} +} diff --git a/src/cli/telemetry/sinks/metric-sink.ts b/src/cli/telemetry/sinks/metric-sink.ts new file mode 100644 index 000000000..6622a34e7 --- /dev/null +++ b/src/cli/telemetry/sinks/metric-sink.ts @@ -0,0 +1,34 @@ +/** + * A destination for metric data. Implementations handle transport (OTel, file, etc.). + */ +export interface MetricSink { + record(value: number, attrs: Record): void; + flush(timeoutMs?: number): Promise; + shutdown(): Promise; +} + +/** + * Fans out to multiple sinks. All sinks receive every record. + * Errors in one sink don't affect others. + */ +export class CompositeSink implements MetricSink { + constructor(private readonly sinks: MetricSink[]) {} + + record(value: number, attrs: Record): void { + for (const sink of this.sinks) { + try { + sink.record(value, attrs); + } catch { + // Individual sink failure must not affect others + } + } + } + + async flush(timeoutMs?: number): Promise { + await Promise.allSettled(this.sinks.map(s => s.flush(timeoutMs))); + } + + async shutdown(): Promise { + await Promise.allSettled(this.sinks.map(s => s.shutdown())); + } +} diff --git a/src/cli/telemetry/sinks/otel-metric-sink.ts b/src/cli/telemetry/sinks/otel-metric-sink.ts new file mode 100644 index 000000000..0bc6721f5 --- /dev/null +++ b/src/cli/telemetry/sinks/otel-metric-sink.ts @@ -0,0 +1,51 @@ +import type { ResourceAttributes } from '../schemas/common-attributes.js'; +import type { MetricSink } from './metric-sink.js'; +import type { Histogram } from '@opentelemetry/api'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { AggregationTemporality, MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; + +export interface OtelMetricSinkConfig { + endpoint: string; + resource: ResourceAttributes; + exportIntervalMs?: number; +} + +export class OtelMetricSink implements MetricSink { + private readonly meterProvider: MeterProvider; + private readonly histogram: Histogram; + + constructor(config: OtelMetricSinkConfig) { + const resource = resourceFromAttributes(config.resource); + const exporter = new OTLPMetricExporter({ + url: `${config.endpoint}/v1/metrics`, + headers: { 'X-Installation-Id': config.resource['agentcore-cli.installation_id'] }, + temporalityPreference: AggregationTemporality.DELTA, + }); + this.meterProvider = new MeterProvider({ + resource, + readers: [ + new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: config.exportIntervalMs ?? 60_000, + exportTimeoutMillis: 5_000, + }), + ], + }); + this.histogram = this.meterProvider + .getMeter('agentcore-cli') + .createHistogram('cli.command_run', { description: 'CLI command execution' }); + } + + record(value: number, attrs: Record): void { + this.histogram.record(value, attrs); + } + + async flush(timeoutMs = 5_000): Promise { + await this.meterProvider.forceFlush({ timeoutMillis: timeoutMs }); + } + + async shutdown(): Promise { + await this.meterProvider.shutdown(); + } +} diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index 26fdcbd1a..44bcf0b77 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -20,6 +20,7 @@ const ICONS = { 'online-eval': '↻', 'policy-engine': '▣', policy: '▢', + 'runtime-endpoint': '◉', } as const; interface ResourceGraphProps { @@ -180,17 +181,34 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res const runtimeStatus = rsEntry?.error ? 'error' : rsEntry?.detail; const runtimeStatusColor = rsEntry?.error ? 'red' : getStatusColor(runtimeStatus); return ( - + + + {agent.endpoints && + Object.entries(agent.endpoints).map(([epName, ep]) => { + // Endpoints inherit deployment state from parent runtime + const parentState = rsEntry?.deploymentState; + const epState = parentState === 'deployed' ? 'deployed' : 'local-only'; + const badge = getDeploymentBadge(epState); + return ( + + {' '} + {ICONS['runtime-endpoint']} {epName} + v{ep.version} + {ep.description && {ep.description}} + {badge && [{badge.text}]} + + ); + })} + ); })} diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index e7e567bb9..2aef3c42c 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -45,7 +45,7 @@ export const COMMAND_DESCRIPTIONS = { fetch: 'Fetch access info for deployed resources.', pause: 'Pause an online eval config. Supports --arn for configs outside the project.', resume: 'Resume a paused online eval config. Supports --arn for configs outside the project.', - run: 'Run on-demand evaluation. Supports --agent-arn for agents outside the project.', + run: 'Run on-demand evaluation.', import: 'Import a runtime, memory, or starter toolkit into this project. [experimental]', telemetry: 'Manage anonymous usage analytics preferences.', update: 'Check for and install CLI updates', diff --git a/src/cli/tui/guards/index.ts b/src/cli/tui/guards/index.ts index 26f83d8bc..cc17a7252 100644 --- a/src/cli/tui/guards/index.ts +++ b/src/cli/tui/guards/index.ts @@ -5,3 +5,4 @@ export { MissingProjectMessage, WrongDirectoryMessage, } from './project'; +export { requireTTY } from './tty'; diff --git a/src/cli/tui/guards/tty.ts b/src/cli/tui/guards/tty.ts new file mode 100644 index 000000000..9f51d0c7d --- /dev/null +++ b/src/cli/tui/guards/tty.ts @@ -0,0 +1,13 @@ +/** + * Guard that checks for an interactive terminal and exits if not found. + * Prevents TUI flows from hanging in CI, piped stdin, or agent automation. + * + * Checks both stdin (Ink reads keyboard input) and stdout (Ink renders TUI output). + * Either being non-TTY means the TUI cannot function. + */ +export function requireTTY(): void { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.error('Error: This command requires an interactive terminal. Use --help to see non-interactive flags.'); + process.exit(1); + } +} diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index ad97362ac..8331d38e6 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -4,6 +4,7 @@ import type { RemovableGatewayTarget, RemovalPreview, RemovalResult } from '../. import type { RemovableCredential } from '../../primitives/CredentialPrimitive'; import type { RemovableMemory } from '../../primitives/MemoryPrimitive'; import type { RemovablePolicyResource } from '../../primitives/PolicyPrimitive'; +import type { RemovableRuntimeEndpoint } from '../../primitives/RuntimeEndpointPrimitive'; import { agentPrimitive, credentialPrimitive, @@ -14,6 +15,7 @@ import { onlineEvalConfigPrimitive, policyEnginePrimitive, policyPrimitive, + runtimeEndpointPrimitive, } from '../../primitives/registry'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -23,6 +25,7 @@ export type { RemovableCredential as RemovableIdentity, RemovableGatewayTarget, RemovablePolicyResource, + RemovableRuntimeEndpoint, }; // ============================================================================ @@ -147,6 +150,13 @@ export function useRemovablePolicies() { return { policies, ...rest }; } +export function useRemovableRuntimeEndpoints() { + const { items: endpoints, ...rest } = useRemovableResources(() => + runtimeEndpointPrimitive.getRemovable() + ); + return { endpoints, ...rest }; +} + // ============================================================================ // Preview Hook // ============================================================================ @@ -218,6 +228,10 @@ export function useRemovalPreview() { (compositeKey: string) => loadPreview(k => policyPrimitive.previewRemove(k), compositeKey), [loadPreview] ); + const loadRuntimeEndpointPreview = useCallback( + (name: string) => loadPreview(n => runtimeEndpointPrimitive.previewRemove(n), name), + [loadPreview] + ); const reset = useCallback(() => { setState({ isLoading: false, preview: null, error: null }); @@ -234,6 +248,7 @@ export function useRemovalPreview() { loadOnlineEvalPreview, loadPolicyEnginePreview, loadPolicyPreview, + loadRuntimeEndpointPreview, reset, }; } @@ -320,3 +335,11 @@ export function useRemovePolicy() { k => k ); } + +export function useRemoveRuntimeEndpoint() { + return useRemoveResource( + (name: string) => runtimeEndpointPrimitive.remove(name), + 'runtime-endpoint', + name => name + ); +} diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index 491d1a72b..ac2e8e40b 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -13,6 +13,7 @@ import { AddGatewayFlow, AddGatewayTargetFlow } from '../mcp'; import { AddMemoryFlow } from '../memory/AddMemoryFlow'; import { AddOnlineEvalFlow } from '../online-eval'; import { AddPolicyFlow } from '../policy'; +import { AddRuntimeEndpointFlow } from '../runtime-endpoint'; import type { AddResourceType } from './AddScreen'; import { AddScreen } from './AddScreen'; import { AddSuccessScreen } from './AddSuccessScreen'; @@ -30,6 +31,7 @@ type FlowState = | { name: 'evaluator-wizard' } | { name: 'online-eval-wizard' } | { name: 'policy-wizard' } + | { name: 'runtime-endpoint-wizard' } | { name: 'agent-create-success'; agentName: string; @@ -179,6 +181,8 @@ function getInitialFlowState(resource?: AddResourceType): FlowState { return { name: 'online-eval-wizard' }; case 'policy': return { name: 'policy-wizard' }; + case 'runtime-endpoint': + return { name: 'runtime-endpoint-wizard' }; default: return { name: 'select' }; } @@ -226,6 +230,9 @@ export function AddFlow(props: AddFlowProps) { case 'policy': setFlow({ name: 'policy-wizard' }); break; + case 'runtime-endpoint': + setFlow({ name: 'runtime-endpoint-wizard' }); + break; } }, []); @@ -459,6 +466,18 @@ export function AddFlow(props: AddFlowProps) { ); } + if (flow.name === 'runtime-endpoint-wizard') { + return ( + setFlow({ name: 'select' })} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + return ( - Checking for existing project... - - ); - } - - // Existing project error phase - if (flow.phase === 'existing-project-error') { - return ( - - - A project already exists at this location. - {flow.existingProjectPath && Found: {flow.existingProjectPath}} - - - Use add agent to create a new agent in the existing project. - - - - - ); + return null; } - // Input phase: ask for project name - if (flow.phase === 'input') { - return ( - - - Create a new AgentCore project - This will create a directory with your project name. - - validateFolderNotExists(name, cwd)} - onSubmit={name => { - flow.setProjectName(name); - flow.confirmProjectName(); - }} - onCancel={handleExit} - /> - - ); - } - - // Create prompt phase - if (flow.phase === 'create-prompt') { - return ( - - - - Project: {flow.projectName} - - - - Would you like to add an agent now? - - - - - - ); - } - - // Create wizard phase - use AddAgentScreen for consistent experience + // Create wizard phase - use AddAgentScreen (separate component, no header conflict) if (flow.phase === 'create-wizard') { return ( to prevent duplicate header flashes + // when Ink transitions between different mounts. + const phase = flow.phase; + const showProjectHeader = phase !== 'input' && phase !== 'existing-project-error'; + const headerContent = showProjectHeader ? ( Project: {flow.projectName} - ); - - const helpText = flow.hasError || allSuccess ? HELP_TEXT.EXIT : undefined; + ) : undefined; + + const helpText = + phase === 'existing-project-error' + ? 'Press Esc to exit' + : phase === 'input' + ? HELP_TEXT.TEXT_INPUT + : phase === 'create-prompt' + ? HELP_TEXT.NAVIGATE_SELECT + : flow.hasError || allSuccess + ? HELP_TEXT.EXIT + : undefined; return ( - + {phase === 'existing-project-error' && ( + + A project already exists at this location. + {flow.existingProjectPath && Found: {flow.existingProjectPath}} + + + Use add agent to create a new agent in the existing project. + + + + )} + + {phase === 'input' && ( + <> + + Create a new AgentCore project + This will create a directory with your project name. + + validateFolderNotExists(name, cwd)} + onSubmit={name => { + flow.setProjectName(name); + flow.confirmProjectName(); + }} + onCancel={handleExit} + /> + + )} + + {phase === 'create-prompt' && ( + <> + + + Project: {flow.projectName} + + + + Would you like to add an agent now? + + + + + + )} + + {phase === 'running' && } + {allSuccess && flow.outputDir && ( + {isInteractive ? ( @@ -352,8 +351,10 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS )} )} + {flow.hasError && ( + Project creation failed. {flow.logFilePath && ( diff --git a/src/cli/tui/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index 0954f00ab..6e99e0cfc 100644 --- a/src/cli/tui/screens/deploy/DeployScreen.tsx +++ b/src/cli/tui/screens/deploy/DeployScreen.tsx @@ -228,13 +228,13 @@ export function DeployScreen({ ); } - // AWS target configuration phase (skip when preSynthesized - we already have context) - if (!skipPreflight && !awsConfig.isConfigured) { - return ( - - - - ); + const showAwsConfig = !skipPreflight && !awsConfig.isConfigured; + + // Brief transitional phases — render nothing to avoid a header flash before the real UI + const awsTransitional = + awsConfig.phase === 'checking' || awsConfig.phase === 'detecting' || awsConfig.phase === 'saving'; + if (showAwsConfig && awsTransitional) { + return null; } // Credentials prompt phase @@ -304,7 +304,11 @@ export function DeployScreen({ ] .filter(Boolean) .join(' · '); - const helpText = context && isInteractive ? `${toggleHints} · ${baseHelpText}` : baseHelpText; + const helpText = showAwsConfig + ? (getAwsConfigHelpText(awsConfig.phase) ?? HELP_TEXT.EXIT) + : context && isInteractive + ? `${toggleHints} · ${baseHelpText}` + : baseHelpText; const screenTitle = diffMode ? 'AgentCore Diff' : 'AgentCore Deploy'; @@ -316,94 +320,105 @@ export function DeployScreen({ const diffMaxHeight = Math.max(6, terminalRows - chromeLines); return ( - - - - {/* Toggleable ResourceGraph view */} - {showResourceGraph && context && ( - - - - )} - - {/* Show deploy status when deploying or complete */} - {showDeployStatus && ( - - - - )} - - {/* Show diff output (diff mode: always; normal mode: Ctrl+D toggle) */} - {(diffMode === true || showDiff) && isDiffLoading && ( - - Loading diff... - - )} - {(diffMode === true || showDiff) && diffSummaries.length > 0 && ( - - - - )} - - {allSuccess && deployOutput && !diffMode && ( - - {deployOutput} - - )} - - {allSuccess && diffMode && ( - - Diff complete - - )} - - {allSuccess && deployNotes.length > 0 && ( - - {deployNotes.map((note, i) => ( - - Note: {note} - - ))} - - )} - - {allSuccess && targetStatuses.length > 0 && ( - - Gateway Targets: - {targetStatuses.map(t => ( - - {' '} - {t.name}: {formatTargetStatus(t.status)} - - ))} - - )} - - {logFilePath && ( - - - - )} - - {allSuccess && !diffMode && ( - 0)} - isInteractive={isInteractive} - onSelect={step => { - if (step.command === 'invoke') { - setShowInvoke(true); - } else if (onNavigate) { - onNavigate(step.command); - } - }} - onBack={onExit} - isActive={allSuccess && !showInvoke} - /> + + {showAwsConfig ? ( + + ) : ( + <> + + + {/* Toggleable ResourceGraph view */} + {showResourceGraph && context && ( + + + + )} + + {/* Show deploy status when deploying or complete */} + {showDeployStatus && ( + + + + )} + + {/* Show diff output (diff mode: always; normal mode: Ctrl+D toggle) */} + {(diffMode === true || showDiff) && isDiffLoading && ( + + Loading diff... + + )} + {(diffMode === true || showDiff) && diffSummaries.length > 0 && ( + + + + )} + + {allSuccess && deployOutput && !diffMode && ( + + {deployOutput} + + )} + + {allSuccess && diffMode && ( + + Diff complete + + )} + + {allSuccess && deployNotes.length > 0 && ( + + {deployNotes.map((note, i) => ( + + Note: {note} + + ))} + + )} + + {allSuccess && targetStatuses.length > 0 && ( + + Gateway Targets: + {targetStatuses.map(t => ( + + {' '} + {t.name}: {formatTargetStatus(t.status)} + + ))} + + )} + + {logFilePath && ( + + + + )} + + {allSuccess && !diffMode && ( + 0)} + isInteractive={isInteractive} + onSelect={step => { + if (step.command === 'invoke') { + setShowInvoke(true); + } else if (onNavigate) { + onNavigate(step.command); + } + }} + onBack={onExit} + isActive={allSuccess && !showInvoke} + /> + )} + )} ); diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 0da441d0c..786c70764 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -122,6 +122,10 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const identityKmsKeyArn = preSynthesized?.identityKmsKeyArn ?? preflight.identityKmsKeyArn; const allCredentials = preSynthesized?.allCredentials ?? preflight.allCredentials; + const [preDeployDiffStep, setPreDeployDiffStep] = useState({ + label: 'Computing diff changes...', + status: 'pending', + }); const [publishAssetsStep, setPublishAssetsStep] = useState({ label: 'Publish assets', status: 'pending' }); const [deployStep, setDeployStep] = useState({ label: 'Deploy to AWS', status: 'pending' }); const [diffStep, setDiffStep] = useState({ label: 'Run CDK diff', status: 'pending' }); @@ -144,6 +148,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const streamOutputsRef = useRef | null>(null); const startDeploy = useCallback(() => { + setPreDeployDiffStep({ label: 'Computing diff changes...', status: 'pending' }); setPublishAssetsStep({ label: 'Publish assets', status: 'pending' }); setDeployStep({ label: 'Deploy to AWS', status: 'pending' }); setDeployOutput(null); @@ -327,6 +332,8 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (!isDiffRunningRef.current) { isDiffRunningRef.current = true; setIsDiffLoading(true); + setPreDeployDiffStep(prev => ({ ...prev, status: 'running' })); + logger.startStep('Computing diff changes...'); switchableIoHost?.setOnRawMessage((code, _level, message, data) => { logger.logDiff(code, message); if (code === 'CDK_TOOLKIT_I4002') { @@ -345,6 +352,8 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState switchableIoHost?.setOnRawMessage(null); isDiffRunningRef.current = false; setIsDiffLoading(false); + logger.endStep('success'); + setPreDeployDiffStep(prev => ({ ...prev, status: 'success' })); } } @@ -569,8 +578,10 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (diffMode) { return skipPreflight ? [diffStep] : [...preflight.steps, diffStep]; } - return skipPreflight ? [publishAssetsStep, deployStep] : [...preflight.steps, publishAssetsStep, deployStep]; - }, [preflight.steps, publishAssetsStep, deployStep, diffStep, skipPreflight, diffMode]); + return skipPreflight + ? [preDeployDiffStep, publishAssetsStep, deployStep] + : [...preflight.steps, preDeployDiffStep, publishAssetsStep, deployStep]; + }, [preflight.steps, preDeployDiffStep, publishAssetsStep, deployStep, diffStep, skipPreflight, diffMode]); const phase: DeployPhase = useMemo(() => { const activeStep = diffMode ? diffStep : deployStep; diff --git a/src/cli/tui/screens/home/CommandListScreen.tsx b/src/cli/tui/screens/home/CommandListScreen.tsx index f2d2f1498..ad2f22c7c 100644 --- a/src/cli/tui/screens/home/CommandListScreen.tsx +++ b/src/cli/tui/screens/home/CommandListScreen.tsx @@ -1,10 +1,8 @@ import { buildLogo, useLayout } from '../../context'; import type { CommandMeta } from '../../utils/commands'; -import { Box, Text, useApp } from 'ink'; +import { Box, Text, useApp, useStdout } from 'ink'; import React, { useEffect } from 'react'; -const MAX_DESC_WIDTH = 50; - function truncateDescription(desc: string, maxLen: number): string { if (desc.length <= maxLen) return desc; return desc.slice(0, maxLen - 1) + '…'; @@ -21,6 +19,9 @@ interface CommandListScreenProps { export function CommandListScreen({ commands }: CommandListScreenProps) { const { exit } = useApp(); const { contentWidth } = useLayout(); + const { stdout } = useStdout(); + const terminalWidth = stdout?.columns ?? 80; + const maxDescWidth = Math.max(20, terminalWidth - 18); const logo = buildLogo(contentWidth); // Exit after render @@ -44,7 +45,7 @@ export function CommandListScreen({ commands }: CommandListScreenProps) { Commands: {visibleCommands.map(cmd => { - const desc = truncateDescription(cmd.description, MAX_DESC_WIDTH); + const desc = truncateDescription(cmd.description, maxDescWidth); const padding = ' '.repeat(Math.max(1, 14 - cmd.title.length)); return ( diff --git a/src/cli/tui/screens/home/HelpScreen.tsx b/src/cli/tui/screens/home/HelpScreen.tsx index 84746f2da..485c263c7 100644 --- a/src/cli/tui/screens/home/HelpScreen.tsx +++ b/src/cli/tui/screens/home/HelpScreen.tsx @@ -3,11 +3,9 @@ import { useLayout } from '../../context'; import { HINTS } from '../../copy'; import { useTextInput } from '../../hooks'; import type { CommandMeta } from '../../utils/commands'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text, useInput, useStdout } from 'ink'; import React, { useEffect, useMemo, useRef, useState } from 'react'; -const MAX_DESC_WIDTH = 50; - function truncateDescription(desc: string, maxLen: number): string { if (desc.length <= maxLen) return desc; return desc.slice(0, maxLen - 1) + '…'; @@ -29,8 +27,18 @@ interface HelpDisplayProps { notice?: React.ReactNode; } -function CommandRow({ item, selected, maxLabelLen }: { item: DisplayItem; selected: boolean; maxLabelLen: number }) { - const desc = truncateDescription(item.command.description, MAX_DESC_WIDTH); +function CommandRow({ + item, + selected, + maxLabelLen, + maxDescWidth, +}: { + item: DisplayItem; + selected: boolean; + maxLabelLen: number; + maxDescWidth: number; +}) { + const desc = truncateDescription(item.command.description, maxDescWidth); const labelLen = item.matchedSubcommand ? item.command.title.length + 3 + item.matchedSubcommand.length : item.command.title.length; @@ -77,10 +85,13 @@ function HelpDisplay({ notice, }: HelpDisplayProps) { const { contentWidth } = useLayout(); + const { stdout } = useStdout(); + const terminalWidth = stdout?.columns ?? 80; const bottomDivider = '─'.repeat(contentWidth); const allItems = [...interactiveItems, ...cliOnlyItems]; const maxLabelLen = getMaxLabelLen(allItems); + const maxDescWidth = Math.max(20, terminalWidth - maxLabelLen - 8); const hasCliOnly = cliOnlyItems.length > 0; const showCliSection = hasCliOnly && (showCliOnly || !!query); @@ -114,6 +125,7 @@ function HelpDisplay({ item={item} selected={idx === clampedIndex} maxLabelLen={maxLabelLen} + maxDescWidth={maxDescWidth} /> ))} @@ -128,6 +140,7 @@ function HelpDisplay({ item={item} selected={interactiveCount + idx === clampedIndex} maxLabelLen={maxLabelLen} + maxDescWidth={maxDescWidth} /> ))} diff --git a/src/cli/tui/screens/import/ImportSelectScreen.tsx b/src/cli/tui/screens/import/ImportSelectScreen.tsx index 21ab114f8..092e255b5 100644 --- a/src/cli/tui/screens/import/ImportSelectScreen.tsx +++ b/src/cli/tui/screens/import/ImportSelectScreen.tsx @@ -1,6 +1,5 @@ import type { SelectableItem } from '../../components/SelectList'; import { SelectScreen } from '../../components/SelectScreen'; -import { Text } from 'ink'; export type ImportType = 'runtime' | 'memory' | 'evaluator' | 'online-eval' | 'starter-toolkit'; @@ -42,17 +41,5 @@ interface ImportSelectScreenProps { } export function ImportSelectScreen({ onSelect, onExit }: ImportSelectScreenProps) { - return ( - - Experimental: this feature imports resources that are already deployed, use with caution - - } - items={IMPORT_OPTIONS} - onSelect={item => onSelect(item.id)} - onExit={onExit} - /> - ); + return onSelect(item.id)} onExit={onExit} />; } diff --git a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx index d5322e9ed..92c56d90e 100644 --- a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx +++ b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx @@ -48,17 +48,12 @@ export function AddOnlineEvalFlow({ isInteractive = true, onExit, onBack, onDev, const result = await listEvaluators({ region }); if (cancelled) return; - // Filter out code-based evaluators — not supported for online evaluation. - // Check both the API response type ('CustomCode') and local config (codeBased). - const codeBasedNames = new Set(projectSpec.evaluators.filter(e => e.config.codeBased).map(e => e.name)); - const items: EvaluatorItem[] = result.evaluators - .filter(e => e.evaluatorType !== 'CustomCode' && !codeBasedNames.has(e.evaluatorName)) - .map(e => ({ - arn: e.evaluatorArn, - name: e.evaluatorName, - type: e.evaluatorType, - description: e.description, - })); + const items: EvaluatorItem[] = result.evaluators.map(e => ({ + arn: e.evaluatorArn, + name: e.evaluatorName, + type: e.evaluatorType, + description: e.description, + })); const agentNames = projectSpec.runtimes.map(a => a.name); diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index f4f85acd2..c8a9e77b3 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -10,6 +10,7 @@ import { useRemovableOnlineEvalConfigs, useRemovablePolicies, useRemovablePolicyEngines, + useRemovableRuntimeEndpoints, useRemovalPreview, useRemoveAgent, useRemoveEvaluator, @@ -20,6 +21,7 @@ import { useRemoveOnlineEvalConfig, useRemovePolicy, useRemovePolicyEngine, + useRemoveRuntimeEndpoint, } from '../../hooks/useRemove'; import { RemoveAgentScreen } from './RemoveAgentScreen'; import { RemoveAllScreen } from './RemoveAllScreen'; @@ -32,6 +34,7 @@ import { RemoveMemoryScreen } from './RemoveMemoryScreen'; import { RemoveOnlineEvalScreen } from './RemoveOnlineEvalScreen'; import { RemovePolicyEngineScreen } from './RemovePolicyEngineScreen'; import { RemovePolicyScreen } from './RemovePolicyScreen'; +import { RemoveRuntimeEndpointScreen } from './RemoveRuntimeEndpointScreen'; import type { RemoveResourceType } from './RemoveScreen'; import { RemoveScreen } from './RemoveScreen'; import { RemoveSuccessScreen } from './RemoveSuccessScreen'; @@ -50,6 +53,7 @@ type FlowState = | { name: 'select-online-eval' } | { name: 'select-policy-engine' } | { name: 'select-policy' } + | { name: 'select-runtime-endpoint' } | { name: 'confirm-agent'; agentName: string; preview: RemovalPreview } | { name: 'confirm-gateway'; gatewayName: string; preview: RemovalPreview } | { name: 'confirm-gateway-target'; tool: RemovableGatewayTarget; preview: RemovalPreview } @@ -59,6 +63,7 @@ type FlowState = | { name: 'confirm-online-eval'; configName: string; preview: RemovalPreview } | { name: 'confirm-policy-engine'; engineName: string; preview: RemovalPreview } | { name: 'confirm-policy'; compositeKey: string; policyName: string; preview: RemovalPreview } + | { name: 'confirm-runtime-endpoint'; endpointName: string; preview: RemovalPreview } | { name: 'loading'; message: string } | { name: 'agent-success'; agentName: string; logFilePath?: string } | { name: 'gateway-success'; gatewayName: string; logFilePath?: string } @@ -69,6 +74,7 @@ type FlowState = | { name: 'online-eval-success'; configName: string; logFilePath?: string } | { name: 'policy-engine-success'; engineName: string; logFilePath?: string } | { name: 'policy-success'; policyName: string; logFilePath?: string } + | { name: 'runtime-endpoint-success'; endpointName: string; logFilePath?: string } | { name: 'remove-all' } | { name: 'error'; message: string }; @@ -85,6 +91,7 @@ interface RemoveFlowProps { | 'agent' | 'gateway' | 'gateway-target' + | 'runtime-endpoint' | 'memory' | 'credential' | 'evaluator' @@ -124,6 +131,8 @@ export function RemoveFlow({ return { name: 'select-policy-engine' }; case 'policy': return { name: 'select-policy' }; + case 'runtime-endpoint': + return { name: 'select-runtime-endpoint' }; default: return { name: 'select' }; } @@ -148,6 +157,11 @@ export function RemoveFlow({ refresh: refreshPolicyEngines, } = useRemovablePolicyEngines(); const { policies, isLoading: isLoadingPolicies, refresh: refreshPolicies } = useRemovablePolicies(); + const { + endpoints: runtimeEndpoints, + isLoading: isLoadingRuntimeEndpoints, + refresh: refreshRuntimeEndpoints, + } = useRemovableRuntimeEndpoints(); // Check if any data is still loading const isLoading = @@ -159,7 +173,8 @@ export function RemoveFlow({ isLoadingEvaluators || isLoadingOnlineEvals || isLoadingPolicyEngines || - isLoadingPolicies; + isLoadingPolicies || + isLoadingRuntimeEndpoints; // Preview hook const { @@ -172,6 +187,7 @@ export function RemoveFlow({ loadOnlineEvalPreview, loadPolicyEnginePreview, loadPolicyPreview, + loadRuntimeEndpointPreview, reset: resetPreview, } = useRemovalPreview(); @@ -185,6 +201,7 @@ export function RemoveFlow({ const { remove: removeOnlineEvalOp, reset: resetRemoveOnlineEval } = useRemoveOnlineEvalConfig(); const { remove: removePolicyEngineOp, reset: resetRemovePolicyEngine } = useRemovePolicyEngine(); const { remove: removePolicyOp, reset: resetRemovePolicy } = useRemovePolicy(); + const { remove: removeRuntimeEndpointOp, reset: resetRemoveRuntimeEndpoint } = useRemoveRuntimeEndpoint(); // Track pending result state const pendingResultRef = useRef(null); @@ -215,6 +232,7 @@ export function RemoveFlow({ 'online-eval-success', 'policy-engine-success', 'policy-success', + 'runtime-endpoint-success', ]; if (successStates.includes(flow.name)) { onExit(); @@ -254,6 +272,9 @@ export function RemoveFlow({ case 'policy': setFlow({ name: 'select-policy' }); break; + case 'runtime-endpoint': + setFlow({ name: 'select-runtime-endpoint' }); + break; case 'all': setFlow({ name: 'remove-all' }); break; @@ -464,6 +485,28 @@ export function RemoveFlow({ [loadPolicyPreview, force, removePolicyOp] ); + const handleSelectRuntimeEndpoint = useCallback( + async (endpointName: string) => { + const result = await loadRuntimeEndpointPreview(endpointName); + if (result.ok) { + if (force) { + setFlow({ name: 'loading', message: `Removing runtime endpoint ${endpointName}...` }); + const removeResult = await removeRuntimeEndpointOp(endpointName, result.preview); + if (removeResult.success) { + setFlow({ name: 'runtime-endpoint-success', endpointName }); + } else { + setFlow({ name: 'error', message: removeResult.error }); + } + } else { + setFlow({ name: 'confirm-runtime-endpoint', endpointName, preview: result.preview }); + } + } else { + setFlow({ name: 'error', message: result.error }); + } + }, + [loadRuntimeEndpointPreview, force, removeRuntimeEndpointOp] + ); + // Auto-select resource when initialResourceName is provided and data is loaded useEffect(() => { if (!initialResourceName || isLoading || hasTriggeredInitialSelection.current) { @@ -500,6 +543,9 @@ export function RemoveFlow({ case 'policy': void handleSelectPolicy(initialResourceName); break; + case 'runtime-endpoint': + void handleSelectRuntimeEndpoint(initialResourceName); + break; } }, 0); }, [ @@ -514,6 +560,7 @@ export function RemoveFlow({ handleSelectOnlineEval, handleSelectPolicyEngine, handleSelectPolicy, + handleSelectRuntimeEndpoint, ]); // Confirm handlers - pass preview for logging @@ -661,6 +708,22 @@ export function RemoveFlow({ [removePolicyOp] ); + const handleConfirmRuntimeEndpoint = useCallback( + async (endpointName: string, preview: RemovalPreview) => { + pendingResultRef.current = null; + setResultReady(false); + setFlow({ name: 'loading', message: `Removing runtime endpoint ${endpointName}...` }); + const result = await removeRuntimeEndpointOp(endpointName, preview); + if (result.success) { + pendingResultRef.current = { name: 'runtime-endpoint-success', endpointName, logFilePath: result.logFilePath }; + } else { + pendingResultRef.current = { name: 'error', message: result.error }; + } + setResultReady(true); + }, + [removeRuntimeEndpointOp] + ); + const resetAll = useCallback(() => { resetPreview(); resetRemoveAgent(); @@ -672,6 +735,7 @@ export function RemoveFlow({ resetRemoveOnlineEval(); resetRemovePolicyEngine(); resetRemovePolicy(); + resetRemoveRuntimeEndpoint(); }, [ resetPreview, resetRemoveAgent, @@ -683,6 +747,7 @@ export function RemoveFlow({ resetRemoveOnlineEval, resetRemovePolicyEngine, resetRemovePolicy, + resetRemoveRuntimeEndpoint, ]); const refreshAll = useCallback(async () => { @@ -696,6 +761,7 @@ export function RemoveFlow({ refreshOnlineEvals(), refreshPolicyEngines(), refreshPolicies(), + refreshRuntimeEndpoints(), ]); }, [ refreshAgents, @@ -707,6 +773,7 @@ export function RemoveFlow({ refreshOnlineEvals, refreshPolicyEngines, refreshPolicies, + refreshRuntimeEndpoints, ]); // Select screen - wait for data to load to avoid arrow position issues @@ -727,6 +794,7 @@ export function RemoveFlow({ onlineEvalCount={onlineEvalConfigs.length} policyEngineCount={policyEngines.length} policyCount={policies.length} + runtimeEndpointCount={runtimeEndpoints.length} /> ); } @@ -861,6 +929,19 @@ export function RemoveFlow({ ); } + if (flow.name === 'select-runtime-endpoint') { + if (initialResourceName && isLoading) { + return null; + } + return ( + void handleSelectRuntimeEndpoint(name)} + onExit={() => setFlow({ name: 'select' })} + /> + ); + } + // Confirmation screens if (flow.name === 'confirm-agent') { return ( @@ -961,6 +1042,17 @@ export function RemoveFlow({ ); } + if (flow.name === 'confirm-runtime-endpoint') { + return ( + void handleConfirmRuntimeEndpoint(flow.endpointName, flow.preview)} + onCancel={() => setFlow({ name: 'select-runtime-endpoint' })} + /> + ); + } + // Success screens if (flow.name === 'agent-success') { return ( @@ -1106,6 +1198,22 @@ export function RemoveFlow({ ); } + if (flow.name === 'runtime-endpoint-success') { + return ( + { + resetAll(); + void refreshAll().then(() => setFlow({ name: 'select' })); + }} + onExit={onExit} + /> + ); + } + // Remove all screen if (flow.name === 'remove-all') { return ; diff --git a/src/cli/tui/screens/remove/RemoveRuntimeEndpointScreen.tsx b/src/cli/tui/screens/remove/RemoveRuntimeEndpointScreen.tsx new file mode 100644 index 000000000..b60b11599 --- /dev/null +++ b/src/cli/tui/screens/remove/RemoveRuntimeEndpointScreen.tsx @@ -0,0 +1,29 @@ +import type { RemovableRuntimeEndpoint } from '../../../primitives/RuntimeEndpointPrimitive'; +import { SelectScreen } from '../../components'; +import React from 'react'; + +interface RemoveRuntimeEndpointScreenProps { + /** List of runtime endpoints that can be removed */ + endpoints: RemovableRuntimeEndpoint[]; + /** Called when an endpoint is selected for removal */ + onSelect: (name: string) => void; + /** Called when user cancels */ + onExit: () => void; +} + +export function RemoveRuntimeEndpointScreen({ endpoints, onSelect, onExit }: RemoveRuntimeEndpointScreenProps) { + const items = endpoints.map(ep => ({ + id: ep.name, + title: ep.name, + description: `${ep.runtimeName} v${ep.version}${ep.description ? ` — ${ep.description}` : ''}`, + })); + + return ( + onSelect(item.id)} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index f562b0ff6..83488ed3a 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -12,6 +12,7 @@ const REMOVE_RESOURCES = [ { id: 'policy', title: 'Policy', description: 'Remove a policy from a policy engine' }, { id: 'gateway', title: 'Gateway', description: 'Remove a gateway' }, { id: 'gateway-target', title: 'Gateway Target', description: 'Remove a gateway target' }, + { id: 'runtime-endpoint', title: 'Runtime Endpoint', description: 'Remove a runtime endpoint' }, { id: 'all', title: 'All', description: 'Reset entire agentcore project' }, ] as const; @@ -38,6 +39,8 @@ interface RemoveScreenProps { policyEngineCount: number; /** Number of policies available for removal */ policyCount: number; + /** Number of runtime endpoints available for removal */ + runtimeEndpointCount: number; } export function RemoveScreen({ @@ -52,6 +55,7 @@ export function RemoveScreen({ onlineEvalCount, policyEngineCount, policyCount, + runtimeEndpointCount, }: RemoveScreenProps) { const items: SelectableItem[] = useMemo(() => { return REMOVE_RESOURCES.map(r => { @@ -113,6 +117,12 @@ export function RemoveScreen({ description = 'No policies to remove'; } break; + case 'runtime-endpoint': + if (runtimeEndpointCount === 0) { + disabled = true; + description = 'No runtime endpoints to remove'; + } + break; case 'all': // 'all' is always available break; @@ -130,6 +140,7 @@ export function RemoveScreen({ onlineEvalCount, policyEngineCount, policyCount, + runtimeEndpointCount, ]); const isDisabled = (item: SelectableItem) => item.disabled ?? false; diff --git a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx index 92087fc66..16e0223e3 100644 --- a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -21,6 +21,7 @@ describe('RemoveScreen', () => { onlineEvalCount={1} policyEngineCount={1} policyCount={1} + runtimeEndpointCount={1} /> ); @@ -51,6 +52,7 @@ describe('RemoveScreen', () => { onlineEvalCount={0} policyEngineCount={0} policyCount={0} + runtimeEndpointCount={0} /> ); diff --git a/src/cli/tui/screens/remove/index.ts b/src/cli/tui/screens/remove/index.ts index 79ebfb8c1..8d77b9b10 100644 --- a/src/cli/tui/screens/remove/index.ts +++ b/src/cli/tui/screens/remove/index.ts @@ -10,5 +10,6 @@ export { RemoveMemoryScreen } from './RemoveMemoryScreen'; export { RemoveOnlineEvalScreen } from './RemoveOnlineEvalScreen'; export { RemovePolicyEngineScreen } from './RemovePolicyEngineScreen'; export { RemovePolicyScreen } from './RemovePolicyScreen'; +export { RemoveRuntimeEndpointScreen } from './RemoveRuntimeEndpointScreen'; export { RemoveScreen, type RemoveResourceType } from './RemoveScreen'; export { RemoveSuccessScreen } from './RemoveSuccessScreen'; diff --git a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx new file mode 100644 index 000000000..2404109fc --- /dev/null +++ b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx @@ -0,0 +1,131 @@ +import { ConfigIO } from '../../../../lib'; +import { runtimeEndpointPrimitive } from '../../../primitives/registry'; +import { ErrorPrompt } from '../../components'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddRuntimeEndpointScreen } from './AddRuntimeEndpointScreen'; +import type { RuntimeEndpointWizardConfig } from './types'; +import React, { useCallback, useEffect, useState } from 'react'; + +/** Map of runtime name → latest deployed version (undefined if not deployed) */ +export type RuntimeVersionMap = Record; + +type FlowState = + | { name: 'loading' } + | { name: 'create-wizard'; runtimeNames: string[]; runtimeVersions: RuntimeVersionMap } + | { name: 'create-success'; endpointName: string; runtimeName: string } + | { name: 'error'; message: string }; + +interface AddRuntimeEndpointFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function AddRuntimeEndpointFlow({ + isInteractive = true, + onExit, + onBack, + onDev, + onDeploy, +}: AddRuntimeEndpointFlowProps) { + const [flow, setFlow] = useState({ name: 'loading' }); + + // Load runtimes and deployed version info on mount + useEffect(() => { + void (async () => { + try { + const configIO = new ConfigIO(); + const spec = await configIO.readProjectSpec(); + const runtimeNames = spec.runtimes.map(r => r.name); + if (runtimeNames.length === 0) { + setFlow({ name: 'error', message: 'No runtimes found. Add a runtime first with `agentcore add agent`.' }); + return; + } + + // Load deployed state to get version info per runtime + const runtimeVersions: RuntimeVersionMap = {}; + if (configIO.configExists('state')) { + try { + const deployedState = await configIO.readDeployedState(); + for (const target of Object.values(deployedState.targets)) { + const runtimes = target.resources?.runtimes ?? {}; + for (const [name, state] of Object.entries(runtimes)) { + if (state.runtimeVersion) { + runtimeVersions[name] = state.runtimeVersion; + } + } + } + } catch { + // Deployed state may not exist yet — that's fine + } + } + + setFlow({ name: 'create-wizard', runtimeNames, runtimeVersions }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setFlow({ name: 'error', message }); + } + })(); + }, []); + + // In non-interactive mode, exit after success + useEffect(() => { + if (!isInteractive && flow.name === 'create-success') { + onExit(); + } + }, [isInteractive, flow.name, onExit]); + + const handleCreateComplete = useCallback((config: RuntimeEndpointWizardConfig) => { + void runtimeEndpointPrimitive + .add({ + runtime: config.runtimeName, + endpoint: config.endpointName, + version: config.version, + description: config.description, + }) + .then(result => { + if (result.success) { + setFlow({ + name: 'create-success', + endpointName: config.endpointName, + runtimeName: config.runtimeName, + }); + return; + } + setFlow({ name: 'error', message: result.error ?? 'Unknown error' }); + }); + }, []); + + if (flow.name === 'loading') { + return null; + } + + if (flow.name === 'create-wizard') { + return ( + + ); + } + + if (flow.name === 'create-success') { + return ( + + ); + } + + return ; +} diff --git a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointScreen.tsx b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointScreen.tsx new file mode 100644 index 000000000..44c884ad0 --- /dev/null +++ b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointScreen.tsx @@ -0,0 +1,268 @@ +import type { SelectableItem } from '../../components'; +import { ConfirmReview, Cursor, Panel, Screen, StepIndicator, WizardSelect } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import type { RuntimeVersionMap } from './AddRuntimeEndpointFlow'; +import type { RuntimeEndpointWizardConfig, RuntimeEndpointWizardStep } from './types'; +import { useAddRuntimeEndpointWizard } from './useAddRuntimeEndpointWizard'; +import { Box, Text, useInput } from 'ink'; +import React, { useMemo, useState } from 'react'; + +const STEP_LABELS: Record = { + runtime: 'Runtime', + endpoint: 'Endpoint', + confirm: 'Confirm', +}; + +type EndpointField = 'name' | 'version' | 'description'; +const ENDPOINT_FIELDS: EndpointField[] = ['name', 'version', 'description']; + +interface AddRuntimeEndpointScreenProps { + onComplete: (config: RuntimeEndpointWizardConfig) => void; + onExit: () => void; + runtimeNames: string[]; + runtimeVersions: RuntimeVersionMap; +} + +export function AddRuntimeEndpointScreen({ + onComplete, + onExit, + runtimeNames, + runtimeVersions, +}: AddRuntimeEndpointScreenProps) { + const skipRuntimeStep = runtimeNames.length === 1; + const wizard = useAddRuntimeEndpointWizard({ skipRuntimeStep }); + + const singleRuntime = skipRuntimeStep ? runtimeNames[0]! : ''; + + // Auto-select the only runtime when skipping runtime step + const effectiveConfig = useMemo(() => { + if (skipRuntimeStep && !wizard.config.runtimeName) { + return { ...wizard.config, runtimeName: singleRuntime }; + } + return wizard.config; + }, [skipRuntimeStep, wizard.config, singleRuntime]); + + // If we skip runtime step, set it immediately on first render + React.useEffect(() => { + if (skipRuntimeStep && !wizard.config.runtimeName) { + wizard.setRuntime(singleRuntime); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const isRuntimeStep = wizard.step === 'runtime'; + const isEndpointStep = wizard.step === 'endpoint'; + const isConfirmStep = wizard.step === 'confirm'; + + // Get the max deployed version for the selected runtime + const maxVersion = effectiveConfig.runtimeName ? runtimeVersions[effectiveConfig.runtimeName] : undefined; + const isDeployed = maxVersion !== undefined; + + // Multi-field state for endpoint step (CustomClaimForm pattern) + const [activeField, setActiveField] = useState('name'); + const [endpointName, setEndpointName] = useState(''); + const [endpointVersion, setEndpointVersion] = useState('1'); + const [endpointDescription, setEndpointDescription] = useState(''); + const [error, setError] = useState(null); + + // Runtime selection items + const runtimeItems: SelectableItem[] = useMemo( + () => runtimeNames.map(name => ({ id: name, title: name })), + [runtimeNames] + ); + + const runtimeNav = useListNavigation({ + items: runtimeItems, + onSelect: item => wizard.setRuntime(item.id), + onExit: () => onExit(), + isActive: isRuntimeStep, + }); + + // Multi-field input handler for endpoint step + useInput( + (input, key) => { + if (!isEndpointStep) return; + + if (key.escape) { + if (skipRuntimeStep) { + onExit(); + } else { + wizard.goBack(); + } + return; + } + + // Tab / Up / Down to cycle fields + if (key.tab || key.upArrow || key.downArrow) { + const idx = ENDPOINT_FIELDS.indexOf(activeField); + if (key.shift || key.upArrow) { + setActiveField(ENDPOINT_FIELDS[(idx - 1 + ENDPOINT_FIELDS.length) % ENDPOINT_FIELDS.length]!); + } else { + setActiveField(ENDPOINT_FIELDS[(idx + 1) % ENDPOINT_FIELDS.length]!); + } + setError(null); + return; + } + + // Enter: advance to next field, or submit on last field + if (key.return) { + const idx = ENDPOINT_FIELDS.indexOf(activeField); + if (idx < ENDPOINT_FIELDS.length - 1) { + // Validate current field before advancing + if (activeField === 'name') { + if (!endpointName.trim()) { + setError('Endpoint name is required'); + return; + } + if (!/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/.test(endpointName.trim())) { + setError('Must begin with a letter, alphanumeric + underscores only (max 48 chars)'); + return; + } + } + if (activeField === 'version') { + const num = Number(endpointVersion); + if (!Number.isInteger(num) || num < 1) { + setError('Version must be a positive integer'); + return; + } + if (isDeployed && num > maxVersion) { + setError(`Version must be between 1 and ${maxVersion} (latest deployed version)`); + return; + } + } + setActiveField(ENDPOINT_FIELDS[idx + 1]!); + setError(null); + return; + } + // Last field — validate and submit + if (!endpointName.trim()) { + setError('Endpoint name is required'); + return; + } + if (!/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/.test(endpointName.trim())) { + setError('Must begin with a letter, alphanumeric + underscores only (max 48 chars)'); + return; + } + const ver = Number(endpointVersion); + if (!Number.isInteger(ver) || ver < 1) { + setError('Version must be a positive integer'); + return; + } + if (isDeployed && ver > maxVersion) { + setError(`Version must be between 1 and ${maxVersion} (latest deployed version)`); + return; + } + const desc = endpointDescription.trim() || undefined; + wizard.setEndpointDetails(endpointName.trim(), ver, desc); + return; + } + + // Text input for active field + if (activeField === 'name' || activeField === 'version' || activeField === 'description') { + if (key.backspace || key.delete) { + if (activeField === 'name') setEndpointName(v => v.slice(0, -1)); + else if (activeField === 'version') setEndpointVersion(v => v.slice(0, -1)); + else setEndpointDescription(v => v.slice(0, -1)); + setError(null); + return; + } + if (input && !key.ctrl && !key.meta) { + if (activeField === 'name') setEndpointName(v => v + input); + else if (activeField === 'version') setEndpointVersion(v => v + input); + else setEndpointDescription(v => v + input); + setError(null); + return; + } + } + }, + { isActive: isEndpointStep } + ); + + // Confirm step navigation + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(effectiveConfig), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + const helpText = isRuntimeStep + ? HELP_TEXT.NAVIGATE_SELECT + : isEndpointStep + ? 'tab/↑↓ switch fields ⏎ submit' + : HELP_TEXT.CONFIRM_CANCEL; + + const headerContent = ; + + const confirmFields = useMemo( + () => [ + { label: 'Runtime', value: effectiveConfig.runtimeName }, + { label: 'Endpoint', value: effectiveConfig.endpointName }, + { label: 'Version', value: String(effectiveConfig.version) }, + ...(effectiveConfig.description ? [{ label: 'Description', value: effectiveConfig.description }] : []), + ], + [effectiveConfig] + ); + + return ( + + + {isRuntimeStep && ( + + )} + + {isEndpointStep && ( + + Runtime: {effectiveConfig.runtimeName} + {isDeployed && Current deployed version: {maxVersion}} + + + Endpoint name: + {activeField === 'name' && !endpointName && } + + {endpointName || e.g., prod, staging} + + {activeField === 'name' && endpointName && } + + + + + Version{isDeployed ? ` (1-${maxVersion})` : ''}:{' '} + + {activeField === 'version' && !endpointVersion && } + + {endpointVersion || 1} + + {activeField === 'version' && endpointVersion && } + + + + Description: + {activeField === 'description' && !endpointDescription && } + + {endpointDescription || (optional)} + + {activeField === 'description' && endpointDescription && } + + + + {error && ( + + {error} + + )} + + )} + + {isConfirmStep && } + + + ); +} diff --git a/src/cli/tui/screens/runtime-endpoint/index.ts b/src/cli/tui/screens/runtime-endpoint/index.ts new file mode 100644 index 000000000..92ff091fe --- /dev/null +++ b/src/cli/tui/screens/runtime-endpoint/index.ts @@ -0,0 +1,2 @@ +export { AddRuntimeEndpointFlow } from './AddRuntimeEndpointFlow'; +export type { RuntimeEndpointWizardConfig } from './types'; diff --git a/src/cli/tui/screens/runtime-endpoint/types.ts b/src/cli/tui/screens/runtime-endpoint/types.ts new file mode 100644 index 000000000..eac8e05c3 --- /dev/null +++ b/src/cli/tui/screens/runtime-endpoint/types.ts @@ -0,0 +1,8 @@ +export interface RuntimeEndpointWizardConfig { + runtimeName: string; + endpointName: string; + version: number; + description?: string; +} + +export type RuntimeEndpointWizardStep = 'runtime' | 'endpoint' | 'confirm'; diff --git a/src/cli/tui/screens/runtime-endpoint/useAddRuntimeEndpointWizard.ts b/src/cli/tui/screens/runtime-endpoint/useAddRuntimeEndpointWizard.ts new file mode 100644 index 000000000..40b109bba --- /dev/null +++ b/src/cli/tui/screens/runtime-endpoint/useAddRuntimeEndpointWizard.ts @@ -0,0 +1,61 @@ +import type { RuntimeEndpointWizardConfig, RuntimeEndpointWizardStep } from './types'; +import { useCallback, useMemo, useState } from 'react'; + +const ALL_STEPS: RuntimeEndpointWizardStep[] = ['runtime', 'endpoint', 'confirm']; + +function getDefaultConfig(): RuntimeEndpointWizardConfig { + return { + runtimeName: '', + endpointName: '', + version: 1, + }; +} + +export function useAddRuntimeEndpointWizard(options?: { skipRuntimeStep?: boolean }) { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState(options?.skipRuntimeStep ? 'endpoint' : 'runtime'); + + const steps = useMemo( + () => (options?.skipRuntimeStep ? ALL_STEPS.filter(s => s !== 'runtime') : ALL_STEPS), + [options?.skipRuntimeStep] + ); + + const currentIndex = steps.indexOf(step); + + const goBack = useCallback(() => { + const idx = steps.indexOf(step); + const prevStep = steps[idx - 1]; + if (prevStep) setStep(prevStep); + }, [steps, step]); + + const setRuntime = useCallback((runtimeName: string) => { + setConfig(c => ({ ...c, runtimeName })); + setStep('endpoint'); + }, []); + + const setEndpointDetails = useCallback((endpointName: string, version: number, description?: string) => { + setConfig(c => ({ + ...c, + endpointName, + version, + ...(description ? { description } : {}), + })); + setStep('confirm'); + }, []); + + const reset = useCallback(() => { + setConfig(getDefaultConfig()); + setStep(options?.skipRuntimeStep ? 'endpoint' : 'runtime'); + }, [options?.skipRuntimeStep]); + + return { + config, + step, + steps, + currentIndex, + goBack, + setRuntime, + setEndpointDetails, + reset, + }; +} diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index caf77c658..5819cbd47 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -73,7 +73,7 @@ interface EnvVar { interface Memory { name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ @max 48 - eventExpiryDuration: number; // @min 7 @max 365 (days) + eventExpiryDuration: number; // @min 3 @max 365 (days) strategies: MemoryStrategy[]; // @min 1, unique by type tags?: Record; } diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index 81b20ef99..1b8d1b668 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -9,6 +9,8 @@ import { InstrumentationSchema, LifecycleConfigurationSchema, NetworkConfigSchema, + RuntimeEndpointNameSchema, + RuntimeEndpointSchema, } from '../agent-env.js'; import { describe, expect, it } from 'vitest'; @@ -540,3 +542,129 @@ describe('AgentEnvSpecSchema - lifecycleConfiguration', () => { } }); }); + +describe('RuntimeEndpointNameSchema', () => { + it.each(['prod', 'staging', 'myEndpoint', 'v1', 'A', 'a' + '0'.repeat(47)])( + 'accepts valid endpoint name "%s"', + name => { + expect(RuntimeEndpointNameSchema.safeParse(name).success).toBe(true); + } + ); + + it('rejects empty string', () => { + expect(RuntimeEndpointNameSchema.safeParse('').success).toBe(false); + }); + + it('rejects name starting with digit', () => { + expect(RuntimeEndpointNameSchema.safeParse('1prod').success).toBe(false); + }); + + it('rejects name with hyphens', () => { + expect(RuntimeEndpointNameSchema.safeParse('my-endpoint').success).toBe(false); + }); + + it('rejects name with special characters', () => { + expect(RuntimeEndpointNameSchema.safeParse('prod!').success).toBe(false); + expect(RuntimeEndpointNameSchema.safeParse('my@endpoint').success).toBe(false); + }); + + it('rejects name exceeding 48 chars', () => { + const name = 'A' + 'b'.repeat(48); + expect(name).toHaveLength(49); + expect(RuntimeEndpointNameSchema.safeParse(name).success).toBe(false); + }); +}); + +describe('RuntimeEndpointSchema', () => { + it('accepts endpoint with version only', () => { + const result = RuntimeEndpointSchema.safeParse({ version: 1 }); + expect(result.success).toBe(true); + }); + + it('accepts endpoint with version and description', () => { + const result = RuntimeEndpointSchema.safeParse({ version: 3, description: 'Production endpoint' }); + expect(result.success).toBe(true); + }); + + it('rejects version < 1', () => { + expect(RuntimeEndpointSchema.safeParse({ version: 0 }).success).toBe(false); + expect(RuntimeEndpointSchema.safeParse({ version: -1 }).success).toBe(false); + }); + + it('rejects non-integer version', () => { + expect(RuntimeEndpointSchema.safeParse({ version: 1.5 }).success).toBe(false); + }); + + it('rejects missing version', () => { + expect(RuntimeEndpointSchema.safeParse({}).success).toBe(false); + expect(RuntimeEndpointSchema.safeParse({ description: 'no version' }).success).toBe(false); + }); +}); + +describe('AgentEnvSpecSchema - endpoints', () => { + const validAgent = { + name: 'TestAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: 'app/TestAgent/', + runtimeVersion: 'PYTHON_3_12', + }; + + it('accepts valid endpoints dictionary', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + endpoints: { + prod: { version: 3, description: 'Production' }, + staging: { version: 2 }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.endpoints).toEqual({ + prod: { version: 3, description: 'Production' }, + staging: { version: 2 }, + }); + } + }); + + it('accepts agent without endpoints (optional)', () => { + const result = AgentEnvSpecSchema.safeParse(validAgent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.endpoints).toBeUndefined(); + } + }); + + it('rejects invalid endpoint name with special characters', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + endpoints: { + 'my-endpoint': { version: 1 }, + }, + }); + expect(result.success).toBe(false); + }); + + it('rejects endpoint with version < 1', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + endpoints: { + prod: { version: 0 }, + }, + }); + expect(result.success).toBe(false); + }); + + it('accepts endpoint with only version (no description)', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + endpoints: { + prod: { version: 5 }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.endpoints!.prod).toEqual({ version: 5 }); + } + }); +}); diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 854959b6c..08aaf3b12 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -137,10 +137,10 @@ describe('MemorySchema', () => { } }); - it('rejects eventExpiryDuration below 7', () => { + it('rejects eventExpiryDuration below 3', () => { const result = MemorySchema.safeParse({ name: 'Test', - eventExpiryDuration: 6, + eventExpiryDuration: 2, strategies: [], }); expect(result.success).toBe(false); @@ -155,11 +155,11 @@ describe('MemorySchema', () => { expect(result.success).toBe(false); }); - it('accepts eventExpiryDuration boundary values (7 and 365)', () => { + it('accepts eventExpiryDuration boundary values (3 and 365)', () => { expect( MemorySchema.safeParse({ name: 'Min', - eventExpiryDuration: 7, + eventExpiryDuration: 3, strategies: [], }).success ).toBe(true); diff --git a/src/schema/schemas/__tests__/deployed-state.test.ts b/src/schema/schemas/__tests__/deployed-state.test.ts index e468e9e85..f1791712b 100644 --- a/src/schema/schemas/__tests__/deployed-state.test.ts +++ b/src/schema/schemas/__tests__/deployed-state.test.ts @@ -50,6 +50,38 @@ describe('AgentCoreDeployedStateSchema', () => { it('rejects missing required fields', () => { expect(AgentCoreDeployedStateSchema.safeParse({ runtimeId: 'rt-123' }).success).toBe(false); }); + + it('accepts runtimeVersion when provided as a valid integer >= 1', () => { + const result = AgentCoreDeployedStateSchema.safeParse({ + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123:role/TestRole', + runtimeVersion: 1, + }); + expect(result.success).toBe(true); + }); + + it('accepts state without runtimeVersion (backwards compatible)', () => { + const result = AgentCoreDeployedStateSchema.safeParse({ + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123:role/TestRole', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.runtimeVersion).toBeUndefined(); + } + }); + + it('rejects runtimeVersion of 0 (must be >= 1)', () => { + const result = AgentCoreDeployedStateSchema.safeParse({ + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123:role/TestRole', + runtimeVersion: 0, + }); + expect(result.success).toBe(false); + }); }); describe('MemoryDeployedStateSchema', () => { diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 04348f3f1..789109a38 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -188,6 +188,31 @@ export const LifecycleConfigurationSchema = z }); export type LifecycleConfiguration = z.infer; +// ============================================================================ +// Runtime Endpoint Schema +// ============================================================================ + +/** + * Endpoint name follows the AgentCore API regex for endpoint aliases. + */ +export const RuntimeEndpointNameSchema = z + .string() + .min(1, 'Endpoint name is required') + .max(48) + .regex( + /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, + 'Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)' + ); + +export const RuntimeEndpointSchema = z.object({ + /** Version number this endpoint points to. Must be >= 1. */ + version: z.number().int().min(1), + /** Optional human-readable description of this endpoint. */ + description: z.string().max(200).optional(), +}); + +export type RuntimeEndpoint = z.infer; + /** * AgentEnvSpec - represents an AgentCore Runtime. * This is a top-level resource in the schema. @@ -231,6 +256,8 @@ export const AgentEnvSpecSchema = z lifecycleConfiguration: LifecycleConfigurationSchema.optional(), /** Filesystem configurations for session-scoped persistent storage. */ filesystemConfigurations: z.array(FilesystemConfigurationSchema).optional(), + /** Named endpoints (version aliases) for this runtime. Keys are endpoint names. */ + endpoints: z.record(RuntimeEndpointNameSchema, RuntimeEndpointSchema).optional(), }) .superRefine((data, ctx) => { if (data.networkMode === 'VPC' && !data.networkConfig) { diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 8d0f35f1c..e61196348 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -126,7 +126,7 @@ export type StreamDeliveryResources = z.infer e.name === evalName); - if (evaluator?.config.codeBased) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Online eval config "${config.name}" references code-based evaluator "${evalName}". Code-based evaluators are not supported for online evaluation.`, - }); - } } } }); diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 2454d6b09..6eeff21b7 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -14,6 +14,8 @@ export const AgentCoreDeployedStateSchema = z.object({ memoryIds: z.array(z.string()).optional(), browserId: z.string().optional(), codeInterpreterId: z.string().optional(), + /** The latest deployed version number of this runtime. */ + runtimeVersion: z.number().int().min(1).optional(), }); export type AgentCoreDeployedState = z.infer; @@ -168,6 +170,17 @@ export const OnlineEvalDeployedStateSchema = z.object({ export type OnlineEvalDeployedState = z.infer; +// ============================================================================ +// Runtime Endpoint Deployed State +// ============================================================================ + +export const RuntimeEndpointDeployedStateSchema = z.object({ + endpointId: z.string().min(1), + endpointArn: z.string().min(1), +}); + +export type RuntimeEndpointDeployedState = z.infer; + // ============================================================================ // Deployed Resource State // ============================================================================ @@ -182,6 +195,7 @@ export const DeployedResourceStateSchema = z.object({ onlineEvalConfigs: z.record(z.string(), OnlineEvalDeployedStateSchema).optional(), policyEngines: z.record(z.string(), PolicyEngineDeployedStateSchema).optional(), policies: z.record(z.string(), PolicyDeployedStateSchema).optional(), + runtimeEndpoints: z.record(z.string(), RuntimeEndpointDeployedStateSchema).optional(), stackName: z.string().optional(), identityKmsKeyArn: z.string().optional(), }); diff --git a/src/test-utils/cli-runner.ts b/src/test-utils/cli-runner.ts index 10cf1bbf3..789624364 100644 --- a/src/test-utils/cli-runner.ts +++ b/src/test-utils/cli-runner.ts @@ -36,6 +36,7 @@ export function spawnAndCollect( const proc = spawn(command, args, { cwd, env: cleanSpawnEnv(extraEnv), + stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; diff --git a/src/tui-harness/__tests__/proof-of-concept.test.ts b/src/tui-harness/__tests__/proof-of-concept.test.ts index 9885bfaef..f4035d5e5 100644 --- a/src/tui-harness/__tests__/proof-of-concept.test.ts +++ b/src/tui-harness/__tests__/proof-of-concept.test.ts @@ -52,6 +52,14 @@ import { afterEach, describe, expect, it } from 'vitest'; const { Terminal } = xtermHeadless; +let ptyAvailable = true; +try { + const p = pty.spawn('/bin/echo', ['test'], { cols: 80, rows: 24 }); + p.kill(); +} catch { + ptyAvailable = false; +} + // --------------------------------------------------------------------------- // Test A: xterm standalone // --------------------------------------------------------------------------- @@ -77,7 +85,7 @@ describe('xterm standalone', () => { // --------------------------------------------------------------------------- // Test B: PTY + xterm wiring // --------------------------------------------------------------------------- -describe('PTY + xterm wiring', () => { +describe.skipIf(!ptyAvailable)('PTY + xterm wiring', () => { let terminal: InstanceType; let ptyProcess: ReturnType | undefined; @@ -119,7 +127,7 @@ describe('PTY + xterm wiring', () => { // --------------------------------------------------------------------------- // Test C: DSR/CPR handler // --------------------------------------------------------------------------- -describe('DSR/CPR handler', () => { +describe.skipIf(!ptyAvailable)('DSR/CPR handler', () => { let terminal: InstanceType; let ptyProcess: ReturnType | undefined; diff --git a/tsconfig.json b/tsconfig.json index ea8523c03..eedb71730 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,7 @@ "src/**/*", "integ-tests/**/*", "e2e-tests/**/*", + "browser-tests/**/*", "scripts/**/*", "vitest.config.ts", "vitest.integ.config.ts", diff --git a/vitest.config.ts b/vitest.config.ts index fec90f1ce..87efb98d9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,13 +33,20 @@ export default defineConfig({ }, plugins: [textLoaderPlugin], test: { + deps: { + optimizer: { + ssr: { + include: ['@aws-sdk/*', '@smithy/*', 'zod', 'commander'], + }, + }, + }, projects: [ { extends: true, test: { name: 'unit', include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], - exclude: ['src/assets/cdk/test/*.test.ts'], + exclude: ['src/assets/cdk/test/*.test.ts', 'src/tui-harness/**'], }, }, { From ccbc9fdd1a595df2c5c3a22d9c1fed741c9731df Mon Sep 17 00:00:00 2001 From: padmak30 Date: Tue, 28 Apr 2026 17:52:19 -0400 Subject: [PATCH 07/27] chore: add sync-preview job to sync public/preview into preview (#149) --- .github/workflows/sync-from-public.yml | 93 ++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml index 94e279079..b0b6ed106 100644 --- a/.github/workflows/sync-from-public.yml +++ b/.github/workflows/sync-from-public.yml @@ -102,3 +102,96 @@ jobs: fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + sync-preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Fetch public preview + run: | + git remote add public https://github.com/aws/agentcore-cli.git + git fetch public preview + + - name: Sync preview with public/preview + run: | + git checkout preview + git reset --hard origin/preview + + # Check if public/preview is already merged + if git merge-base --is-ancestor public/preview HEAD; then + echo "✅ preview is already up to date with public/preview" + exit 0 + fi + + # Merge but exclude .github/workflows/ (GITHUB_TOKEN lacks workflow permission) + if git merge public/preview --no-commit --no-ff; then + git checkout HEAD -- .github/workflows/ 2>/dev/null || true + git commit -m "chore: sync preview with public/preview" + git push origin preview + echo "✅ preview synced successfully" + else + echo "⚠️ Conflict detected in preview" + + # Capture conflicted files before aborting + conflicted_files=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "Unable to determine conflicted files") + git merge --abort + + # Check if a sync PR already exists + existing_pr=$(gh pr list --base "preview" --search "Merge public/preview" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + + if [ -n "$existing_pr" ]; then + echo "ℹ️ PR #$existing_pr already exists, skipping" + exit 0 + fi + + conflict_branch="sync-conflict-preview-$(date +%Y%m%d-%H%M%S)" + git checkout -b "$conflict_branch" + + git merge public/preview --no-commit --no-ff || true + git checkout HEAD -- .github/workflows/ 2>/dev/null || true + git add -A + git commit -m "chore: sync preview with public/preview (conflicts present) + + This automated sync detected merge conflicts that require manual resolution. + + Source: public/preview (https://github.com/aws/agentcore-cli) + Target: preview + + Please resolve conflicts and merge this PR." || true + + git push origin "$conflict_branch" + + gh pr create \ + --title "🔀 [Sync Conflict] Merge public/preview → preview" \ + --body "## Automated Sync Conflict + + This PR was automatically created because merging \`public/preview\` into \`preview\` encountered conflicts. + + **Source:** \`preview\` from [aws/agentcore-cli](https://github.com/aws/agentcore-cli) + **Target:** \`preview\` + + ### Action Required + 1. \`git fetch origin && git checkout $conflict_branch\` + 2. Resolve merge conflicts + 3. \`git add . && git commit\` + 4. \`git push origin $conflict_branch\` + 5. Merge this PR + + ### Files with Conflicts + \`\`\` + $conflicted_files + \`\`\`" \ + --base "preview" \ + --head "$conflict_branch" || echo "⚠️ Failed to create PR" + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 04aa12e0889de2454a85274377f7b526c83d02b8 Mon Sep 17 00:00:00 2001 From: padmak30 Date: Wed, 29 Apr 2026 10:39:25 -0400 Subject: [PATCH 08/27] fix: use --track origin/preview to avoid ambiguous checkout (#152) --- .github/workflows/sync-from-public.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml index b0b6ed106..143b09b34 100644 --- a/.github/workflows/sync-from-public.yml +++ b/.github/workflows/sync-from-public.yml @@ -123,7 +123,7 @@ jobs: - name: Sync preview with public/preview run: | - git checkout preview + git checkout --track origin/preview git reset --hard origin/preview # Check if public/preview is already merged From 8ea3afbdb8065932c2d12b067026573e738d0749 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 14:01:46 -0400 Subject: [PATCH 09/27] =?UTF-8?q?=F0=9F=94=80=20[Sync=20Conflict]=20Merge?= =?UTF-8?q?=20public/main=20=E2=86=92=20main=20(#165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add GitHub Action for automated PR review via AgentCore Harness (#934) * feat: add GitHub Action for automated PR review via AgentCore Harness Adds a workflow that reviews PRs using Bedrock AgentCore Harness. The harness runs an AI agent in an isolated microVM with gh, git, and pre-cloned repos that fetches PR diffs and posts review comments. Workflow: - Triggers on PR open/reopen for agentcore-cli-devs team members - Supports manual workflow_dispatch for any PR URL - Adds/removes ai-reviewing label during review - Authenticates via GitHub OIDC to assume AWS role Files: - .github/workflows/pr-ai-review.yml — main workflow - .github/scripts/python/harness_review.py — harness invocation script - .github/scripts/python/harness_config.py — config from env vars - .github/scripts/models/ — local boto3 service model (InvokeHarness not yet in standard boto3) Required secrets: - HARNESS_AWS_ROLE_ARN — IAM role ARN for OIDC - HARNESS_ACCOUNT_ID — AWS account ID - HARNESS_ID — Harness ID * refactor: replace local service model with raw HTTP + SigV4 signing Eliminates the 220KB bundled service model by using direct HTTP requests with SigV4 authentication to invoke the harness endpoint. No extra dependencies needed — urllib3, SigV4Auth, and EventStreamBuffer are all part of botocore/boto3. Rejected: invoke_agent_runtime API | server rejects harness ARNs with ResourceNotFoundException Confidence: high Scope-risk: moderate * refactor: inline harness config into review script Remove separate harness_config.py — env vars are read directly in harness_review.py. One less file to maintain, config is still driven entirely by environment variables set in the GitHub workflow. * refactor: extract invoke_harness helper for cleaner main flow * refactor: simplify config and improve script readability - Replace HARNESS_ACCOUNT_ID + HARNESS_ID with single HARNESS_ARN env var - Extract prompts into separate .md files in .github/scripts/prompts/ - Extract stream parsing into print_stream() function - Add close_group() helper to deduplicate ::group:: bookkeeping * refactor: separate event parsing from display logic Extract parse_events() generator to handle binary stream decoding, keeping print_stream() focused on formatting and log groups. * docs: add explanatory comments to harness review functions * refactor: derive region from HARNESS_ARN instead of separate env var Eliminates HARNESS_REGION env var — the region is extracted from the ARN directly, so there's no risk of a mismatch causing confusing SigV4 auth errors. * chore: rename label to agentcore-harness-reviewing * refactor: move auth check to job level so entire review is skipped early Split into authorize + ai-review jobs. The ai-review job only runs if the PR author is authorized (team member or write access) or if triggered via workflow_dispatch. Removes repeated if conditions from every step. * chore: exclude AI prompt templates from prettier Prompt markdown files use intentional formatting that prettier would reflow, breaking the prompt structure. * fix: buffer streaming text to avoid per-token log lines in GitHub Actions (#946) Each text delta from the harness was printed individually with flush, creating a separate log line per token. Now text is buffered and flushed as complete lines at block boundaries. * fix: allow code-based evaluators in online eval configs (#947) * fix: allow code-based evaluators in online eval configs Remove restrictions that blocked code-based evaluators from being used in online evaluation configs. The service now supports code-based evaluators for online evaluation. Changes: - Remove code-based evaluator block in OnlineEvalConfigPrimitive - Remove code-based evaluator validation in schema superRefine - Remove code-based evaluator filter in TUI evaluator picker * style: fix prettier formatting * fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs (#949) * fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs When commands are invoked without flags in non-interactive environments (CI, piped stdin, agent automation), the CLI falls through to Ink TUI rendering which hangs indefinitely. Add a requireTTY() guard at every TUI entry point that checks process.stdout.isTTY and exits with a helpful error message directing users to --help for non-interactive flags. Closes #685 * fix: check both stdin and stdout isTTY in requireTTY guard The hang from #685 is caused by stdin not being a TTY (Ink reads keyboard input from stdin), not stdout. Check both stdin and stdout so the guard fires for piped stdin, redirected stdout, and CI environments where both are non-TTY. * fix: agentcore dev not working in windows (#951) * fix: use pull_request_target for fork PR support (#958) * fix: make label step non-blocking for fork PRs Fork PRs get read-only GITHUB_TOKEN regardless of workflow permissions, causing the addLabels API call to fail with 403. This crashed the entire job before the review could run. continue-on-error lets the review proceed even when labeling fails. * fix: use pull_request_target for full write access on fork PRs pull_request gives a read-only GITHUB_TOKEN for fork PRs, preventing labels and secrets from working. pull_request_target runs in the base repo context with full permissions. This is safe because we never check out or execute fork code — the harness fetches the PR diff via the GitHub API. * fix: lower eventExpiryDuration minimum from 7 to 3 days (closes #744) (#956) The AWS CreateMemory API allows a minimum of 3 days, but the CLI schema was rejecting values below 7. Update the Zod schema, LLM compacted types, import clamping logic, and all related tests. * fix: display session ID after CLI invoke completes (#957) * fix: display session ID after CLI invoke completes (closes #664) The streaming and non-streaming invoke responses include a session ID from the runtime, but the CLI paths discarded it. Now prints the session ID and a resume command hint after invoke output. * fix: include sessionId in AGUI protocol invoke result * test: add browser tests for agent inspector (#938) * feat: add telemetry schemas and client (#941) * chore: bump version to 0.11.0 (#967) Co-authored-by: github-actions[bot] * fix(invoke): auto-generate session ID for bearer-token invocations (#953) Closes #840 When invoking an agent with a bearer token (OAuth/CUSTOM_JWT) and no session ID, `AgentCoreMemoryConfig` raised a Pydantic validation error because `session_id=None` is rejected. Unlike SigV4 callers, bearer-token callers do not get a server-side auto-generated runtime session ID. Two-layer fix: 1. CLI synthesizes a UUID in `invoke` action when `--bearer-token` is set and `--session-id` is missing, using the existing `generateSessionId` helper. Covers both explicit `--bearer-token` and the CUSTOM_JWT auto-fetch path. 2. Strands memory session templates (http, agui, a2a) synthesize a UUID when `session_id` is falsy before constructing AgentCoreMemoryConfig. Protects direct runtime callers (curl, custom apps) who forget the `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` header. Snapshot tests updated. * fix: show 'Computing diff changes...' step during deploy diff phase (#952) The deploy TUI appeared frozen for 5-15 seconds between preflight completion and 'Publish assets' while cdkToolkitWrapper.diff() ran silently with no step marked as running. Add a dedicated pre-deploy diff step that transitions running -> success around the diff call so StepProgress always has something to highlight. Closes #781 * test: split browser tests into its own job, fix logs path (#975) * feat(invoke): add --prompt-file and stdin support for long prompts (#974) * feat(invoke): add --prompt-file and stdin support for long prompts Long prompts hit shell argument limits (E2BIG, typically 128KB-2MB) when passed as positional args. This adds two new sources: - --prompt-file : read prompt from a file - piped stdin: when no prompt is given and stdin is not a TTY, read the prompt from stdin Precedence is hybrid and backward-compatible: --prompt > positional > --prompt-file > stdin --prompt-file combined with piped stdin content returns an explicit collision error rather than silently picking one. Closes #686 * docs(invoke): document --prompt-file and stdin support * fix(import): remove experimental warning from import command (#977) The import feature has stabilized and no longer needs the experimental label. * fix: duplicate header flash and help menu truncation (closes #895, closes #637) (#955) - Return null during brief transitional phases to prevent Ink from rendering a header that gets immediately replaced by a different frame - Consolidate CreateScreen phases into a single Screen mount - Make help menu description width responsive to terminal size - Remove hardcoded 50-char description truncation limit * test: configure git in browser tests workflow (#976) * feat: add project-name option to create (#969) * Add project-name option to create * fix: address review feedback — restore name description and move backfill logic * ci: bump the github-actions group across 1 directory with 4 updates (#964) Bumps the github-actions group with 4 updates in the / directory: [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials), [actions/github-script](https://github.com/actions/github-script), [softprops/action-gh-release](https://github.com/softprops/action-gh-release) and [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action). Updates `aws-actions/configure-aws-credentials` from 5 to 6 - [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/v5...v6) Updates `actions/github-script` from 8 to 9 - [Commits](https://github.com/actions/github-script/compare/v8...v9) Updates `softprops/action-gh-release` from 2 to 3 - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) Updates `slackapi/slack-github-action` from 3.0.1 to 3.0.2 - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Changelog](https://github.com/slackapi/slack-github-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/slackapi/slack-github-action/compare/v3.0.1...v3.0.2) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: aws-actions/configure-aws-credentials dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: slackapi/slack-github-action dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump aws-cdk-lib (#962) Bumps the aws-cdk group with 1 update in the / directory: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib). Updates `aws-cdk-lib` from 2.248.0 to 2.250.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.250.0/packages/aws-cdk-lib) --- updated-dependencies: - dependency-name: aws-cdk-lib dependency-version: 2.250.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump postcss from 8.5.8 to 8.5.10 (#961) Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.10. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.10 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump secretlint from 11.4.1 to 12.2.0 (#916) Bumps [secretlint](https://github.com/secretlint/secretlint) from 11.4.1 to 12.2.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v11.4.1...v12.2.0) --- updated-dependencies: - dependency-name: secretlint dependency-version: 12.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @vitest/coverage-v8 from 4.1.2 to 4.1.5 (#915) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.2 to 4.1.5. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @secretlint/secretlint-rule-preset-recommend (#914) Bumps [@secretlint/secretlint-rule-preset-recommend](https://github.com/secretlint/secretlint) from 11.4.1 to 12.2.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v11.4.1...v12.2.0) --- updated-dependencies: - dependency-name: "@secretlint/secretlint-rule-preset-recommend" dependency-version: 12.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the aws-sdk group across 1 directory with 14 updates (#912) Bumps the aws-sdk group with 14 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@aws-sdk/client-application-signals](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-application-signals) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agentcore](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agentcore-control](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore-control) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cloudformation](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudformation) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cloudwatch-logs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudwatch-logs) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-resource-groups-tagging-api](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-resource-groups-tagging-api) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-xray](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-xray) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/credential-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/credential-providers) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cognito-identity-provider](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cognito-identity-provider) | `3.1036.0` | `3.1037.0` | Updates `@aws-sdk/client-application-signals` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-application-signals/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-application-signals) Updates `@aws-sdk/client-bedrock` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock) Updates `@aws-sdk/client-bedrock-agent` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agent) Updates `@aws-sdk/client-bedrock-agentcore` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agentcore) Updates `@aws-sdk/client-bedrock-agentcore-control` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore-control/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agentcore-control) Updates `@aws-sdk/client-bedrock-runtime` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-runtime) Updates `@aws-sdk/client-cloudformation` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudformation/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cloudformation) Updates `@aws-sdk/client-cloudwatch-logs` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudwatch-logs/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cloudwatch-logs) Updates `@aws-sdk/client-resource-groups-tagging-api` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-resource-groups-tagging-api/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-resource-groups-tagging-api) Updates `@aws-sdk/client-s3` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-s3) Updates `@aws-sdk/client-sts` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-sts) Updates `@aws-sdk/client-xray` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-xray/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-xray) Updates `@aws-sdk/credential-providers` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/credential-providers/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/packages/credential-providers) Updates `@aws-sdk/client-cognito-identity-provider` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cognito-identity-provider/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cognito-identity-provider) --- updated-dependencies: - dependency-name: "@aws-sdk/client-application-signals" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agent" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore-control" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-runtime" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudformation" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudwatch-logs" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cognito-identity-provider" dependency-version: 3.1034.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-resource-groups-tagging-api" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-xray" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/credential-providers" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump hono from 4.12.12 to 4.12.14 (#868) Bumps [hono](https://github.com/honojs/hono) from 4.12.12 to 4.12.14. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.12...v4.12.14) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.14 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump esbuild from 0.27.4 to 0.28.0 (#862) Bumps [esbuild](https://github.com/evanw/esbuild) from 0.27.4 to 0.28.0. - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.27.4...v0.28.0) --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.28.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * test: speed up CI and fix mock cleanup gaps (#989) * test: speed up CI and fix mock cleanup gaps - Node 20 only on PRs (full matrix on main) - 3-way vitest sharding for unit tests with blob report merging - Pre-bundle heavy deps (AWS SDK, Smithy, zod, commander) via deps.optimizer - Exclude tui-harness from unit test project (not production code) - Add afterEach(vi.restoreAllMocks) to 3 files with mock cleanup gaps - Move inline consoleSpy.mockRestore() to afterEach in logs-eval tests - Skip PTY tests when node-pty spawn is unavailable * style: fix prettier formatting in build-and-test.yml * fix: enable include-hidden-files for blob artifact upload upload-artifact@v7 defaults include-hidden-files to false, which skips the .vitest-reports directory. Also fail loudly if no files found. * feat: runtime endpoint support in AgentCore CLI (#979) * feat: add runtime endpoint support to AgentCore CLI - Schema: endpoints field on AgentEnvSpec, runtimeVersion in deployed state - Primitive: RuntimeEndpointPrimitive with add/remove/preview - TUI: Add and Remove flows with multi-field form - Status: endpoints nested under agents with deployment badges - Deploy: parseRuntimeEndpointOutputs + buildDeployedState pipeline * fix: correct output key prefix for runtime endpoint parsing The CFN output keys include the AgentEnvironment construct prefix (Agent{PascalName}) which was missing from the parser pattern. * fix: remove .omc state files and unused useCallback import - Remove .omc/ from git tracking, add to .gitignore - Remove unused useCallback import in AddRuntimeEndpointScreen.tsx * fix: shorten runtime endpoint description to prevent TUI overflow The description "Named endpoint (version alias) for a runtime" was too long and wrapped to the next line in the Add Resource menu. Shortened to "Named endpoint for a runtime". * fix: validate runtime endpoint version is a positive integer - Add explicit Number.isInteger check before schema validation - Change Commander parser from parseInt to Number so floats like 3.5 are caught instead of silently truncated * fix: use agent/endpoint composite key to prevent React key collision Endpoint names can collide across runtimes (e.g., both have "prod"). Changed React key from epName to agent.name/epName to prevent duplicate key warnings that pollute the TUI viewport. * fix: render runtime endpoints in status --type runtime-endpoint When filtering by --type runtime-endpoint, agents array is empty so the agents section (which nests endpoints) never renders. Added a standalone Runtime Endpoints section that shows when endpoints exist but agents don't (i.e., when type-filtering). * fix: add runtime-endpoint to status --help --type documentation The --type option help text was missing runtime-endpoint from the list of valid resource types. * fix: return richer JSON response from add runtime-endpoint add now returns { success, endpointName, agent, version } instead of sparse { success: true }, matching the richer response shape from remove runtime-endpoint. * fix: validate endpoint version against deployed runtime version - TUI: show "Current deployed version: N" and valid range (1-N) - TUI: reject version exceeding latest deployed version - CLI: check deployed-state.json for max version, reject if exceeded - If runtime not deployed, only positive integer check applies * chore: remove planning and bug bash docs from PR * fix: use composite key and parentName for endpoint identification - Add parentName field to ResourceStatusEntry for structured parent linking - Use runtimeName/endpointName composite key in remove/preview/getRemovable - Status command filters endpoints by parentName instead of parsing detail string - React keys use structured parentName/name instead of display strings * test: add comprehensive unit tests for RuntimeEndpointPrimitive 23 tests covering add(), remove(), previewRemove(), getRemovable(): - Runtime lookup, duplicate detection, version validation - Composite key removal targeting correct runtime - Empty endpoints dict cleanup - Version validation against deployed state - Richer JSON response shape * fix: remove dead findGatewayTargetReferences stub * fix: use BasePrimitive configIO instead of ad-hoc ConfigIO in add() * fix: use Number() instead of parseInt in TUI version validation * chore: fix prettier formatting * fix: use T[] instead of Array to satisfy eslint array-type rule * feat: add gateway import command with executionRoleArn support (#855) * feat: add gateway import command and unhide import from TUI Add `agentcore import gateway --arn ` to import existing AWS gateways (with all targets) into a local CLI project. Also remove import from the HIDDEN_FROM_TUI list so it appears in the interactive TUI. - Add AWS SDK wrappers for gateway/target list/get APIs - Add import-gateway.ts with multi-resource CFN import support - Add resourceName schema field to preserve actual AWS gateway name during import - Register gateway in TUI ImportSelectScreen and ImportProgressScreen - Extend ARN pattern, deployed state, and CFN constants for gateway type * fix: expand ARN input to show full resource ARN and add gateway support The ARN text input was truncating long ARNs. Use the expandable prop to wrap text across multiple lines. Also add gateway to the ARN validation pattern and resource type labels. * refactor: remove --name and --yes flags from import gateway command Remove --name (confusing local rename) and --yes (no prompts to confirm) from the gateway import command. The gateway's AWS name is used directly. * feat: add e2e tests for import gateway command Add end-to-end tests that create a real AWS gateway with an MCP server target, import it via `agentcore import gateway --arn`, and verify the resulting agentcore.json fields and deployed-state.json entries. New files: - e2e-tests/fixtures/import/setup_gateway.py: creates gateway + target - e2e-tests/fixtures/import/common.py: gateway wait helpers - e2e-tests/fixtures/import/cleanup_resources.py: gateway cleanup Constraint: Tests follow the existing import-resources.test.ts pattern Confidence: high Scope-risk: narrow * chore: gitignore bugbash-resources.json and .omc/ * feat: preserve gateway executionRoleArn during import Extract roleArn from the AWS GetGateway response and map it to executionRoleArn in agentcore.json. On deploy, CDK uses iam.Role.fromRoleArn() instead of creating a new role, keeping the original permissions intact. Constraint: imported roles use mutable: false so CDK cannot modify them Rejected: always create new role | breaks permissions on re-import Confidence: high Scope-risk: narrow * refactor: export internal gateway import functions for unit testing Add @internal exports for toGatewayTargetSpec, resolveOutboundAuth, toGatewaySpec, and buildCredentialArnMap to enable direct unit testing of the pure mapping functions in import-gateway.ts. Confidence: high Scope-risk: narrow * test: add unit tests for mcpServer target mapping and credential resolution Bugbash coverage for toGatewayTargetSpec and resolveOutboundAuth: - mcpServer with no auth, OAuth, and API_KEY credentials - Credential resolution warnings when ARNs not in project - Targets with no MCP configuration - OAuth scopes pass-through and empty scopes omission 8 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for apiGateway, openApiSchema, smithyModel, lambda target mapping Bugbash coverage for toGatewayTargetSpec non-mcpServer target types: - apiGateway: restApiId, stage, toolFilters, toolOverrides mapping - openApiSchema: S3 URI mapping, missing URI warning - smithyModel: S3 URI mapping, missing URI warning - lambda: S3 tool schema to lambdaFunctionArn mapping, missing ARN, inline-only schema warning, progress messages - Unrecognized target type warning 13 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for toGatewaySpec gateway-level field mapping Bugbash coverage for toGatewaySpec AWS-to-CLI schema mapping: - Authorizer types: NONE, AWS_IAM, CUSTOM_JWT with all JWT fields - CUSTOM_JWT customClaims with full claim structure - Semantic search: SEMANTIC/KEYWORD/missing protocolConfiguration - Exception level: DEBUG/undefined/other values - Policy engine: ARN name extraction, mode preservation - Optional fields: resourceName, description, tags, executionRoleArn - Edge cases: empty tags object omitted, empty JWT arrays omitted 23 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for handleImportGateway full flow validation Bugbash coverage for the main gateway import flow: - Happy path: successful import with --arn, config written, result verified - Rollback: pipeline failure restores original config, noResources error - Duplicate detection: name collision, resource ID already tracked - Name validation: invalid name regex, --name override preserves resourceName - Auto-select: single gateway auto-selected, multiple gateways error, no gateways error - Target mapping: skipped targets warning, non-READY gateway continues 12 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for buildCredentialArnMap and CFN template matching Bugbash coverage for credential resolution and CFN resource matching: - buildCredentialArnMap: reads ARN-to-name map from deployed state, handles multiple credentials, empty/missing state, thrown errors - findLogicalIdByProperty: gateway by Name property, resourceName fallback, target by Name, Fn::Join/Fn::Sub intrinsic function patterns, regex boundary check prevents false substring matches - findLogicalIdsByType: single gateway fallback, single target fallback, multiple targets prevent fallback 14 tests, all passing. Confidence: high Scope-risk: narrow * fix: exclude already-deployed logical IDs when building import resource list When a project already contains an imported resource (gateway + target, agent, memory, etc.), a subsequent import of a different resource that shares a Name with the deployed one caused buildResourcesToImport to resolve the OLD logical ID via findLogicalIdByProperty. The resulting CFN change set then failed with "Resources [...] passed in ResourceToImport are already in a stack and cannot be imported." Thread the deployed template into every buildResourcesToImport callback and skip logical IDs already present in the stack during both the name lookup and the single-candidate fallback. Constraint: GatewayTarget has no structural parent ref in Properties — only the physical-ID tuple (GatewayIdentifier, TargetId), so scoping the synth search by parent gateway is not available. Rejected: Parse Fn::Ref/Fn::GetAtt from GatewayIdentifier | brittle intrinsic traversal Rejected: Match by physical TargetId | synth template has no physical ID for new resources Rejected: Strip deployed resources from synth before lookup | breaks buildImportTemplate Confidence: high Scope-risk: narrow Directive: new callbacks into executeCdkImportPipeline must accept and honor the deployedTemplate arg Not-tested: multi-region / cross-stack-identifier collisions * fix(import): translate AccessDenied on GetGateway to a friendly not-found error When importing a gateway by a well-formed but nonexistent ARN, the BedrockAgentCore control plane returns AccessDenied (not ResourceNotFound) for bedrock-agentcore:GetGateway. The CLI surfaced the raw SDK error — which is misleading when the caller has full Admin access and the gateway simply doesn't exist. Catch AccessDenied from getGatewayDetail and return a targeted failure with guidance: the gateway is likely nonexistent / the ARN is malformed / the caller lacks GetGateway. Point the user at list-gateways so they can confirm. Constraint: AWS returns AccessDenied instead of ResourceNotFound for nonexistent gateway IDs; we cannot distinguish the two server-side Rejected: Client-side ARN existence probe via ListGateways | extra latency on the happy path and still racy Confidence: high Scope-risk: narrow Directive: Do not swallow other error classes here — only AccessDenied is reinterpreted * fix(import): detect AWS_REGION / ARN region mismatch before import Previously when a user ran import with AWS_REGION=us-west-2 against a us-east-1 ARN, and no deployment targets existed yet, the CLI silently synthesized a default target from the ARN's region and proceeded — so the user would unknowingly import from a different region than they intended, leaving agentcore.json pointed at the wrong region and causing cross-region CFN errors on later deploy. Short-circuit resolveImportTarget when AWS_REGION (or AWS_DEFAULT_REGION) is set and disagrees with the ARN's region, and ask the user to reconcile explicitly. Constraint: Must fail fast before any side effects (writing aws-targets.json, calling GetGateway) Rejected: Warn-and-continue | a silent cross-region import is exactly the failure mode we're preventing Confidence: high Scope-risk: narrow Directive: Only throw when both env region AND ARN region are present — do not require AWS_REGION to be set * fix(import): allow re-import of resource after remove without CDK pipeline After `agentcore remove gateway`, the gateway entry remains in deployed-state.json (correctly, since CFN still manages it) but is removed from agentcore.json. A subsequent `agentcore import gateway` would reject with "already imported" because the dedup check only looked at deployed-state. Now, when a resource exists in deployed-state but not in agentcore.json, the import skips the CDK pipeline and just re-adds the resource to agentcore.json. Applies to both the gateway-specific and generic import orchestrators. * style: fix prettier formatting for import-utils and ArnInputScreen * fix(import): address PR review blockers B4, B6, B7, H2, H5, H7, H8 - B4: Hard-fail when credential provider ARN is not found in deployed state instead of silently dropping outboundAuth - B7: Preserve outboundAuth on lambda→lambdaFunctionArn mapping and allow OAUTH/NONE auth types for lambdaFunctionArn targets - H2: Remove re-import fast path; run full CDK pipeline so out-of-band targets are properly imported. Treat noResources as success for re-imports since all resources are already in the CFN stack - H5: Replace hardcoded arn:aws: with partition-agnostic arn:[^:]+: in ARN validation and region extraction regexes - H7/H8: Add regex validation and max length for executionRoleArn, use GatewayNameSchema for resourceName, add refine ensuring both fields are set together or both omitted * fix(import): remove credential ARN from error messages to resolve CodeQL alert CodeQL flagged clear-text logging of credential provider ARNs. The target name provides sufficient context for the user to identify the issue. * fix(import): remove resourceName/executionRoleArn co-variance refine (#996) The refine required both fields to be set together, but resourceName is always needed on import (to preserve the AWS name) while executionRoleArn is only present when the gateway has a custom role. Gateways without a custom role (service auto-creates one) fail the refine because resourceName is set but executionRoleArn is not. Keep the individual field validations (GatewayNameSchema for resourceName, regex for executionRoleArn). * fix(e2e): separate gateway import test and add PR-changed test detection (#999) Split gateway import e2e tests into their own file so they can run independently with faster setup (only setup_gateway.py instead of all 4 resource scripts). Update the PR e2e workflow to detect changed test files and include them alongside the strands-bedrock baseline, using only the main CDK source to reduce CI time. Constraint: PR workflow must always run strands-bedrock as a baseline Rejected: Keep gateway in combined suite | setup creates unnecessary resources when running gateway-only Confidence: high Scope-risk: narrow * fix(e2e): add debug logging for gateway import CI failures (#1001) * fix(e2e): add debug logging for gateway import failures Print the import log file and CloudFormation stack events when the gateway import test fails to help diagnose IMPORT_ROLLBACK_IN_PROGRESS errors in CI. Confidence: high Scope-risk: narrow * fix(e2e): add shared debug logging for all import test failures Add dumpImportDebugInfo to e2e-helper that prints the import log file and CloudFormation stack events when an import fails. Used by both import-resources and import-gateway tests to diagnose CI failures. Confidence: high Scope-risk: narrow * chore: bump version to 0.12.0 (#1002) Co-authored-by: github-actions[bot] * test: remove 44 render-only and framework-testing tests (#998) * 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. * 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 * feat: add CloudWatch traces API for web UI (#997) * fix: remove CONFIG_DIR exclusion from zip stage to preserve dependency agentcore/ packages (#1015) PR #844 correctly removed the flat name-based agentcore exclusion and threaded rootDir through copySourceTree, but the same CONFIG_DIR check remained in collectFiles/collectFilesSync (the zip stage). Since the zip stage operates on the staging directory — not the project root — the check incorrectly stripped any top-level agentcore/ Python package installed by uv (e.g., langgraph_checkpoint_aws/agentcore/) from the deployment artifact, causing ModuleNotFoundError at runtime. The CONFIG_DIR exclusion is only needed in copySourceTree (which copies from the project root into staging). By the time we zip, the project config dir was already filtered out — the only agentcore/ in staging is a legitimate dependency package. Closes #843 * ci: add coordinated main + preview release workflow (#995) * ci: add coordinated main + preview release workflow Adds a single workflow_dispatch that releases both branches together, ensuring they stay in sync on npm. * fix: address review — bump script compat, pre-publish verification, drop unused artifacts - Preview bump now uses `prerelease --prerelease-tag preview` which the bump-version.ts script actually accepts - Added verify-merges job that checks both main and preview have the expected versions before either publish runs (prevents drift) - Both publish jobs now depend on verify-merges instead of each other, so neither publishes unless both PRs are confirmed merged - Removed upload-artifact steps from test jobs since publish jobs rebuild from source post-merge * fix: auto-rebase preview onto main in preflight step Instead of failing when preview isn't rebased, the workflow now rebases automatically. If there are conflicts, it aborts and directs the user to resolve manually. * ci: add sync-preview workflow, simplify release preflight - New sync-preview.yml: runs on every push to main, auto-rebases preview onto main. Silently skips on conflicts (no failure). - Release workflow preflight reverted to a simple check — relies on sync-preview having already done the rebase. * fix: use merge instead of rebase for preview sync Rebase overwrites preview-specific values (package version, tests). Merge preserves preview's divergent files and only conflicts when both branches touch the same lines. * fix: concurrency control, conflict notifications, CDK tag TODO - Add concurrency group to sync-preview to prevent parallel race - On merge conflict, auto-create a GitHub issue (deduplicated) instead of silently skipping - Add TODO comment for CDK preview dist-tag in prepare-preview * fix: create PR with conflict markers instead of issue on merge conflict On conflict, sync-preview now: - Creates a branch with the merge conflict markers committed - Opens a PR targeting preview with resolution instructions - Tags the original commit author for visibility - Deduplicates (skips if a sync PR is already open) * chore(deps): bump the aws-sdk group with 14 updates (#1024) Bumps the aws-sdk group with 14 updates: | Package | From | To | | --- | --- | --- | | [@aws-sdk/client-application-signals](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-application-signals) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agentcore](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agentcore-control](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore-control) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cloudformation](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudformation) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cloudwatch-logs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudwatch-logs) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-resource-groups-tagging-api](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-resource-groups-tagging-api) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-xray](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-xray) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/credential-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/credential-providers) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cognito-identity-provider](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cognito-identity-provider) | `3.1037.0` | `3.1038.0` | Updates `@aws-sdk/client-application-signals` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-application-signals/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-application-signals) Updates `@aws-sdk/client-bedrock` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock) Updates `@aws-sdk/client-bedrock-agent` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agent) Updates `@aws-sdk/client-bedrock-agentcore` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agentcore) Updates `@aws-sdk/client-bedrock-agentcore-control` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore-control/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agentcore-control) Updates `@aws-sdk/client-bedrock-runtime` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-runtime) Updates `@aws-sdk/client-cloudformation` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudformation/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cloudformation) Updates `@aws-sdk/client-cloudwatch-logs` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudwatch-logs/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cloudwatch-logs) Updates `@aws-sdk/client-resource-groups-tagging-api` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-resource-groups-tagging-api/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-resource-groups-tagging-api) Updates `@aws-sdk/client-s3` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-s3) Updates `@aws-sdk/client-sts` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-sts) Updates `@aws-sdk/client-xray` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-xray/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-xray) Updates `@aws-sdk/credential-providers` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/credential-providers/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/packages/credential-providers) Updates `@aws-sdk/client-cognito-identity-provider` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cognito-identity-provider/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cognito-identity-provider) --- updated-dependencies: - dependency-name: "@aws-sdk/client-application-signals" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agent" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore-control" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-runtime" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudformation" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudwatch-logs" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-resource-groups-tagging-api" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-xray" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/credential-providers" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cognito-identity-provider" dependency-version: 3.1038.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-sdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the aws-cdk group with 2 updates (#1025) Bumps the aws-cdk group with 2 updates: [@aws-cdk/toolkit-lib](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/@aws-cdk/toolkit-lib) and [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib). Updates `@aws-cdk/toolkit-lib` from 1.24.0 to 1.25.0 - [Release notes](https://github.com/aws/aws-cdk-cli/releases) - [Commits](https://github.com/aws/aws-cdk-cli/commits/@aws-cdk/toolkit-lib@v1.25.0/packages/@aws-cdk/toolkit-lib) Updates `aws-cdk-lib` from 2.250.0 to 2.251.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.251.0/packages/aws-cdk-lib) --- updated-dependencies: - dependency-name: "@aws-cdk/toolkit-lib" dependency-version: 1.25.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-cdk - dependency-name: aws-cdk-lib dependency-version: 2.251.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @opentelemetry/resources from 2.6.1 to 2.7.0 (#1026) Bumps [@opentelemetry/resources](https://github.com/open-telemetry/opentelemetry-js) from 2.6.1 to 2.7.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/v2.6.1...v2.7.0) --- updated-dependencies: - dependency-name: "@opentelemetry/resources" dependency-version: 2.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @secretlint/secretlint-rule-preset-recommend (#1028) Bumps [@secretlint/secretlint-rule-preset-recommend](https://github.com/secretlint/secretlint) from 12.2.0 to 12.3.1. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v12.2.0...v12.3.1) --- updated-dependencies: - dependency-name: "@secretlint/secretlint-rule-preset-recommend" dependency-version: 12.3.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump secretlint from 12.2.0 to 12.3.1 (#1029) Bumps [secretlint](https://github.com/secretlint/secretlint) from 12.2.0 to 12.3.1. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v12.2.0...v12.3.1) --- updated-dependencies: - dependency-name: secretlint dependency-version: 12.3.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @opentelemetry/sdk-metrics from 2.6.1 to 2.7.0 (#1030) Bumps [@opentelemetry/sdk-metrics](https://github.com/open-telemetry/opentelemetry-js) from 2.6.1 to 2.7.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/v2.6.1...v2.7.0) --- updated-dependencies: - dependency-name: "@opentelemetry/sdk-metrics" dependency-version: 2.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(ci): update snapshots after CDK version sync in release workflow (#1033) The release workflow syncs @aws/agentcore-cdk to the latest npm version in the asset template, but never updates the snapshot file. This causes the asset snapshot test to fail because the snapshot still holds the old version string. * fix(ci): enable coverage collection in sharded unit test runs (#1034) The coverage report on PRs was empty (0/0 Unknown%) because the sharded unit-test jobs ran without --coverage. Without that flag, V8 coverage data is never collected, so the blob reports contain no coverage maps. The merge-reports step then merges undefined entries and produces empty results. Also fixes the coverage report action referencing a nonexistent vitest.unit.config.ts (should be vitest.config.ts). * fix(ci): move snapshot update after build in release workflow (#1036) Move `npm run test:update-snapshots` from inside the CDK sync step (before build) to its own step after `npm run build`. The test suite needs built artifacts to pass — running it before the build caused 18 test failures. * fix(ci): install uv in release workflow prepare steps (#1038) The create.test.ts tests require uv for Python project scaffolding. The Build and Test workflow installs it via astral-sh/setup-uv, but the release workflow's prepare steps were missing it, causing test failures during the snapshot update step. * chore: bump version to 0.12.1 * fix: remove CDK version auto-sync from release workflow and restore caret range (#1044) Remove the auto-sync step that bumped @aws/agentcore-cdk during releases — version updates will be managed manually via PRs. Restore the caret range (^0.1.0-alpha.19) in the asset and snapshot that was dropped by the auto-sync in #1042. * fix: add Accept header to HTTP protocol invocation proxy (#1051) The dev web UI proxy for HTTP protocol agents doesn't include an Accept header when forwarding requests to /invocations. The bedrock-agentcore runtime SDK checks for Accept: text/event-stream before enabling SSE streaming (via @fastify/sse reply.sse), so streaming handlers always get a 406 response in the inspector. A2A and AGUI protocol paths already send the header correctly — this brings HTTP in line with them. Using "text/event-stream, */*" so streaming agents get SSE enabled while non-streaming agents can respond in whatever format they need. * feat: add telemetry audit mode with FileSystemSink (#1014) * feat: add FilesystemSink for telemetry audit mode * feat: instrument help.modes with telemetry, add audit integ test * refactor: move harness resources to .github/harness/ (#992) * refactor: move harness resources to .github/harness/ Move PR reviewer harness files into a dedicated .github/harness/ directory, separate from the general .github/scripts/ used by Strands workflows. - Move harness_review.py, prompts/ to .github/harness/ - Add Dockerfile for the harness container (dual-token: CLONE_TOKEN for git clones, GITHUB_TOKEN for gh CLI/PR comments) - Add README documenting the harness directory - Update pr-ai-review workflow to reference new path - Update .prettierignore for new prompts location * fix(harness): update Dockerfile comment to accurately describe token handling Tokens are baked into image layers at build time — the previous comment incorrectly implied they were not stored. Updated to make the security posture explicit: the image itself must be treated as a secret. * refactor(harness): use boto3 invoke_harness instead of raw SigV4 HTTP Replace manual SigV4 signing + urllib3 + EventStreamBuffer parsing with the native boto3 bedrock-agentcore client's invoke_harness method. This simplifies the code significantly and leverages the typed event stream response from the SDK. Rejected: keep raw HTTP approach | boto3 now supports invoke_harness natively Confidence: high Scope-risk: narrow Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * Revert "refactor: move harness resources to .github/harness/ (#992)" This reverts commit aef3890e460a9a06db7f8465a157588bc4b0f7b3. * refactor: move harness resources to .github/harness/ and use boto3 invoke_harness - Move harness_review.py, prompts/ to .github/harness/ - Add Dockerfile for the harness container (dual-token setup) - Add README documenting the harness directory - Update pr-ai-review workflow to reference new path - Replace manual SigV4 signing + urllib3 with native boto3 invoke_harness - Update .prettierignore for new prompts location * feat: update @aws/agent-inspector to 0.3.0 * fix(harness): add error handling for invoke_harness API call (#1056) Wrap the invoke_harness_streaming call in a try/except so boto3 errors (bad credentials, network issues, invalid ARN) produce a clean error message instead of a raw traceback in GitHub Actions logs. * chore: bump version to 0.12.2 Co-authored-by: github-actions[bot] * ci: cut full e2e time in half via vitest sharding (#1016) * ci: shard e2e full suite across 6 runners - Add 6-way vitest sharding to the cdk-source matrix (2 → 12 parallel runners) - Isolate import test resources per run via RESOURCE_SUFFIX to prevent concurrent conflicts * fix: import RESOURCE_SUFFIX in cleanup_resources.py * refactor: consolidate cli-config into global-config (#802) * feat: make parsing resilient to individual failures (#1062) * fix: forward custom headers in bearer token invoke paths (#1065) Custom headers passed via -H/--header were silently dropped when using CUSTOM_JWT auth because invokeWithBearerToken and invokeWithBearerTokenStreaming did not merge options.headers into the request headers. Extract buildBearerInvokeHeaders() (paralleling the existing buildMcpBearerHeaders) and use it in both invoke paths. Closes #1052 * feat: wire telemetry into all add.* commands (#1050) * feat: wire telemetry withCommandRun into all add.* commands * refactor: extract cliCommandRun helper, apply to all add.* primitives * test: add audit file assertions for all add.* telemetry * test: add telemetry audit assertions to existing add integ tests * refactor: extract shared audit test utils into src/test-utils/audit.ts * fix: address review feedback — guard telemetry init, replaceAll, unknown fallback, TUI try/catch * fix: AgentPrimitive TUI try/catch, standardize uses safeParse * refactor: extract standalone assertTelemetry helper * refactor: rename audit.ts to telemetry-helper.ts, clarify method names * refactor: move assertTelemetry into TelemetryHelper as assertMetricEmitted * feat: add telemetry to TUI add paths via withAddTelemetry * fix: review feedback — withAddTelemetry safety, standardize handles undefined, MCP agent attrs, policy TUI attrs * fix: remove unnecessary type assertion * fix: address review — document standardize cast, add policy-engine + episodic telemetry tests * refactor: centralize gateway target type mapping in common-shapes * fix: preserve original function error with telemetry wrapper * refactor: extract telemetryAttrs into a single line * feat: wire up telemetry for addAgent * fix: resolve e2e import test concurrency races (#1067) * fix: resolve e2e import test concurrency races Fix two independent concurrency issues causing flaky e2e import tests: 1. TOCTOU race in evaluator import (import-evaluator.ts): The beforeConfigWrite hook lists all online eval configs then fetches details for each with Promise.all. If a config is deleted between the list and get calls, the API throws 'Online evaluation configuration not found' and the entire import fails. Fixed by using Promise.allSettled and filtering out disappeared configs. 2. Resource name collisions across parallel CI shards (setup_*.py): Python setup scripts generated resource names using int(time.time()) (second-level precision). Parallel CI shards starting in the same second would collide with ConflictException. The test already passes a unique RESOURCE_SUFFIX env var but scripts ignored it for naming. Added NAME_SUFFIX to common.py that prefers RESOURCE_SUFFIX when set, and updated all setup scripts to use it. * chore: remove unused time imports from setup scripts * feat: evo preview features — config bundles, batch evaluation, recommendations, AB testing (#1068) * feat: add sync workflow * fix: formatting * fix: only sync to main branch * fix: codeql permissions * feat: add configuration bundle support Add ConfigBundle as a new resource type with full lifecycle: - Schema: ConfigBundleSchema with name validation, component configurations - Primitive: ConfigBundlePrimitive for add/remove operations - API client: SigV4-signed HTTP requests for config bundle CRUD operations - Deploy: post-deploy hook to sync config bundles with control plane - Status: config-bundle resource type in status command - TUI: add wizard (name, description, components, branch, commit message), remove flow, ResourceGraph integration - State: carry forward configBundles across redeploys in buildDeployedState * fix: use correct SigV4 service name for config bundle API The signing service must be 'bedrock-agentcore' for all stages, not 'bedrock-agentcore-control' for prod. The endpoint hostname differs from the signing service name. * fix: config bundle deploy and TUI defaults - Add config bundle post-deploy setup to TUI deploy flow (useDeployFlow) - Add clientToken to config bundle update API call - Add parentVersionIds on update (required by API) - Default branchName to "main" and commitMessage when not specified - Add placeholders for branch/message in TUI wizard - Fallback to find-by-name or create when update fails (stale IDs) - Remove debug logging from actions.ts * fix: use nullish coalescing for branchName default * feat: add edit config-bundle command with deploy diff check - Add `agentcore edit config-bundle` CLI command with --bundle, --components, --components-file, --description, --branch, --message, --json flags - Add interactive TUI wizard for editing config bundles (select bundle, input method, components, commit message, branch name, confirm) - Add diff check to post-deploy: skip API update when components and description are unchanged, avoiding unnecessary version creation - Use getConfigurationBundleVersion instead of getConfigurationBundle to avoid branch-not-found errors on bundles created with different branches - Align default branch name to 'mainline' (API default) instead of 'main' - For updates, inherit branch from current API state when not specified * test: add unit tests for edit config-bundle and deploy diff check - post-deploy-config-bundles: 13 tests covering create, update, skip (diff check), delete, branch inheritance, fallback paths, errors - ConfigBundlePrimitive.edit: 7 tests covering component updates, optional field handling, missing bundle errors, field preservation - useEditConfigBundleWizard: 16 tests covering step navigation, setters, goBack, reset, currentIndex tracking, step labels * fix: address review comments * fix: remove duplicate config-bundle subcommand from edit command * feat: config bundle version history CLI + TUI (#46) * chore: remove edit config-bundle command Users should edit agentcore.json directly to update config bundles. Removes the edit CLI command, TUI screens, wizard hooks, and tests. * feat: add config-bundle CLI commands for version history Adds `agentcore config-bundle` with three subcommands: - `versions` — list version history grouped by branch - `get-version` — view specific version details and components - `diff` — client-side deep diff between two versions Also adds filter support (branchName, latestPerBranch, createdBy) to the listConfigurationBundleVersions API client. * feat: add config bundle hub TUI screens Add TUI screens for browsing config bundles, viewing version history with branch grouping, version detail drill-down, and diff comparison between versions. * fix: resolve config bundle versionId when falling back to list API (#49) The Recommendation API requires versionId to be non-null when using configurationBundle input. When resolveBundleByName fell back to the list API (bundle not in deployed state), it returned no versionId, causing a 400 validation error. Now calls getConfigurationBundle after list to fetch the latest versionId. Also adds versionId to the ResolvedBundle interface and returns it from the deployed-state fast path. * chore: remove get-version subcommand from config-bundle CLI The versions --json and diff commands cover all practical use cases. Keeps the command surface lean: versions + diff only. * feat: add Recommendations API, TUI wizard, and CLI commands (#45) * feat: add Recommendation API wrappers, CLI commands, and operations layer Implement the Recommendations/Optimization feature for AgentCore CLI: - SigV4-signed HTTP client for Start/Get/List/Delete Recommendation (DP) - Operations layer with orchestration, polling, and local storage - CLI commands: evals recommend, evals recommendation history/delete, run promote - 27 unit tests covering API, storage, and orchestration logic - Live-validated field names and ARN formats against prod API * feat: add recommendation TUI wizard with session discovery and multi-evaluator support - Add full recommendation wizard TUI (type, agent, evaluators, input, trace source, sessions, confirm) - Add session discovery flow: discover sessions from CloudWatch, multi-select specific sessions - Support both CloudWatch logs and session ID trace sources - Pass selected sessionIds to recommendation API cloudwatchLogs config - Add request ID capture and error detail extraction for debugging FAILED recommendations - Fix recommendation API test mocks (add headers for requestId capture) - Add scrollable list support (maxVisibleItems) to MultiSelectList, SelectList, WizardSelect - Wire recommendation screen into App.tsx and EvalHubScreen navigation * feat: add session span fetching, recommendation tests, and TUI integration - Add fetch-session-spans module for retrieving OTEL spans from aws/spans and log records from runtime log groups with session ID filtering - Add comprehensive tests for fetch-session-spans (9 tests) and extend run-recommendation tests (12 new tests covering file input, spans-file trace source, tool-desc auto-fetch, error handling, ARN passthrough) - Wire recommendation hub, history screen, and list/delete CLI commands - Update TUI routing for recommendation flows from eval and run hubs - Add recommendation constants (poll intervals, terminal statuses) * chore: remove list commands and promote stub, fix agents→runtimes rename Remove `agentcore list recommendations` and `agentcore list recommendation --id` commands (top-level `list` command deleted entirely). Remove `run promote` stub. Fix typecheck errors from agents→runtimes schema rename in recommendation files. * feat: batch evaluation — stateless eval API, TUI wizard, local storage (#26) * feat: add EvaluationJob resource — schema, primitive, deploy hook, TUI, and tests Phase 1 of EvalJobRunner: CRUD + deploy integration for the EvaluationJob control plane resource. - Schema: EvaluationJobSchema in agentcore.json, deployed state tracking - Primitive: EvaluationJobPrimitive with add/remove lifecycle - AWS client: SigV4-signed HTTP wrappers for EvalJob CP operations - Deploy: post-deploy hook creates/updates/deletes eval jobs imperatively - CFN outputs: parse eval job execution role ARN from stack outputs - TUI: add evaluation-job wizard flow + remove flow integration - Tests: 53 tests across schema, primitive, AWS client, deploy hook, and TUI * feat: add `run evaluation-job` command with DP API wrappers and orchestration - Data plane API wrappers (RunEvaluationJob, GetEvaluationJobRun, ListEvaluationJobRuns) with SigV4 signing against bedrock-agentcore service - Orchestration: resolve job from deployed state, generate runId, start run, poll for completion, fetch results from CW Logs output group - CLI command: `agentcore run evaluation-job --job --session-id ` with --json output and progress callbacks - Tests: 17 new tests covering DP wrappers, runId generation, orchestration (error handling, polling, CW Logs result parsing) * feat: complete US1/US2 quick wins — run name, cancel, update, stage-aware endpoints - Add --run flag to `run evaluation-job` for custom run name prefixes - Add `run cancel-evaluation-job` command with StopEvaluationJobRun DP API - Add `update evaluation-job` primitive method and CLI subcommands - Add `agentcore update experiment` parent command (backward-compatible) - Make CP/DP endpoints stage-aware via AGENTCORE_STAGE env var (beta/gamma/prod) - Fix beta SigV4 service name (bedrock-agentcore vs bedrock-agentcore-control) - Update AddEvaluationJobFlow success screen with next-steps guidance * feat: add TUI run wizard, progress steps, and local result storage for eval jobs - Add RunEvalJobFlow TUI: select job → enter sessions → name run → confirm → execute - Add StepProgress display during eval job polling (starting → polling → fetching → saving) - Add elapsed time counter during run execution - Add eval-job-storage module: save/load/list run results per job in .cli/eval-job-results/ - Auto-save results on both CLI and TUI paths - Add "Evaluation Job" option to TUI Run screen - Add 9 unit tests for eval-job-storage * feat: add CloudWatch session discovery to eval job TUI wizard - Add source type picker: "Discover from CloudWatch" vs "Enter manually" - Add lookback days input (1-90 days) for CloudWatch discovery - Discover sessions via CW Insights query using agent's runtimeId - Multi-select from discovered sessions with span count + timestamps - Auto-fallback to manual entry when agent not deployed (no runtimeId) - Improve error display: show failed step in StepProgress before transitioning * feat: migrate evaluation from resource CRUD to stateless batch evaluation Replace the old EvaluationJob resource model (create/update/delete via agentcore.json + deploy hooks) with a flat BatchEvaluation API model: - Add `run batch-evaluation` and `run stop-batch-evaluation` CLI commands - Add batch evaluation TUI wizard under the Run menu - Add SigV4 API client for batch eval endpoints (start/get/list/stop) - Add CloudWatch results fetching from outputDataConfig - Remove all old evaluation-job infrastructure: primitive, deploy hook, schema, TUI add/remove screens, CP CRUD operations - Remove evaluationJobs from agentcore.json schema Tested end-to-end on gamma (account 998846730471) with Builtin.Faithfulness evaluator against 3 agent sessions — all returning correct scores. * chore: remove executionRoleArn now that FAS creds are live on gamma The batch evaluation API no longer requires an execution role ARN. Remove the --execution-role CLI option and all executionRoleArn plumbing from the API client and orchestration layer. * Revert "chore: remove executionRoleArn now that FAS creds are live on gamma" This reverts commit f1706ff7ea4b7695d1466e609cde29e38cb00afb. * refactor: move stop-batch-evaluation to top-level stop command Move `agentcore run stop-batch-evaluation` to `agentcore stop batch-evaluation` as a higher-level verb, consistent with pause/resume pattern. * fix: evo cleanup — sync public 0.7.1 + 6 bug fixes (#52) * ci: use draft releases for PR tarballs to avoid notifying watchers (#745) * feat: add code-based evaluator support (#739) * feat: add code-based evaluator support Add managed and external code-based evaluator support across schema, CLI flags, TUI wizard, and template scaffolding. Block code-based evaluators from online eval configs at schema, CLI, and TUI layers. * temp: use pyproject.toml with vendored SDK wheel Vendor the SDK wheel and add binary-aware template rendering until the SDK is published to PyPI. To be removed once the SDK is publicly available. * fix: update asset snapshot and regenerate package-lock.json - Update asset file listing snapshot for new evaluator templates - Regenerate package-lock.json to fix stale aws-cdk bundled dep (@aws-cdk/cloud-assembly-schema 52.2.0 -> 53.11.0) * fix: show correct evaluator type in status display Status command was hardcoding "LLM-as-a-Judge" for all evaluators. Now derives the label from item.config.codeBased to distinguish code-based evaluators. * feat: add additionalPolicies field to managed code-based evaluator config Add additionalPolicies to ManagedCodeBasedConfigSchema supporting both inline .json policy files and managed policy ARNs. Auto-populate with execution-role-policy.json when scaffolding managed evaluators. * revert: remove vendored wheel support and requirements.txt The SDK is now on PyPI (bedrock-agentcore>=1.6.0). Remove: - Binary-aware template rendering (.whl copy logic) - Vendored wheels from evaluator assets - requirements.txt references from scaffold messages pyproject.toml now pulls directly from PyPI. * fix: remove vendored wheel and pin bedrock-agentcore>=1.6.0 Remove the last vendored .whl from src/assets and update pyproject.toml to require bedrock-agentcore>=1.6.0 from PyPI. Update asset snapshot accordingly. * feat(import): add runtime and memory import subcommands with TUI wizard (#763) * feat(import): add runtime and memory import subcommands Add `agentcore import runtime` and `agentcore import memory` subcommands to import existing AWS resources into a CLI project. Includes 2-phase CFN import, source code copying, and shared utilities. Also adds TODO.md tracking entrypoint detection improvement and CFN Phase 2 handler investigation, and IMPORT_TESTING_SUMMARY.md with full E2E test results. Constraint: AWS API returns modified entryPoint array (with otel wrapper), not original Constraint: Commander.js parent options shadow same-named child options Rejected: --source flag on runtime subcommand | conflicts with parent import --source Confidence: high Scope-risk: moderate Not-tested: CFN Phase 2 import for runtimes (service-side HandlerInternalFailure) * fix(import): fail on undetectable entrypoint instead of silent fallback extractEntrypoint() now returns undefined when it cannot find a file with a known extension (.py/.ts/.js) in the API's entryPoint array, instead of silently falling back to main.py. Adds --entrypoint flag so users can specify the entrypoint manually when auto-detection fails. Constraint: AWS API returns modified entryPoint array with otel wrappers, not original Rejected: Silent fallback to main.py | wrong entrypoint causes silent deploy failures Confidence: high Scope-risk: narrow * chore: remove TODO.md and testing summary from branch * test(import): add unit tests for entrypoint detection and runtime import handler 11 tests for extractEntrypoint covering otel wrappers, missing/empty arrays, multiple extensions, and extensionless entries. 8 tests for handleImportRuntime covering entrypoint failure, --entrypoint override, missing --code, nonexistent source path, and duplicate runtime names. * fix(import): address PR review feedback - Validate entrypoint file exists inside --code directory - Improve --code help text to clarify it points to the entrypoint folder - Validate AWS credentials match target account via STS GetCallerIdentity - Fix project name prefix stripping to only strip known prefix, not any underscore - Rename sanitize() to replaceUnderscoresWithDashes() for clarity - Use existing Dockerfile template from assets instead of hardcoded duplicate * refactor: change --id to --arn on import runtime and memory subcommands Users now provide the full resource ARN instead of just the ID. The runtime/memory ID is extracted from the ARN's last path segment. * fix(import): extract reflectionNamespaces for EPISODIC memory strategies toMemorySpec was not mapping reflectionNamespaces from the API response, causing EPISODIC strategy imports to fail Zod schema validation which requires reflectionNamespaces for EPISODIC type strategies. * fix(import): validate ARN format, region, and account before extracting resource ID Previously, --arn was parsed with a blind split('/').pop() with no validation. Now parseAndValidateArn checks the ARN matches the expected format, resource type, and that region/account match the deployment target. * fix(import): throw on missing required fields in getMemoryDetail instead of silent defaults Previously, missing id/arn/name/eventExpiryDuration/strategy.type were silently replaced with empty strings or default values, hiding API response issues that would cause broken imports downstream. * fix(import): detect already-imported resources early and improve CFN error messages Check deployed-state.json before making any config changes to catch resources already imported in the current project. Also detect the "already exists in stack" CFN error and provide a friendlier message explaining the resource must be removed from the other stack first. * feat(import): capture tags during memory import Fetch tags via ListTagsForResource API and include them in the imported memory config. Tags already flow through the CLI schema and CDK construct, they just weren't being read from the API during import. * feat(import): capture encryptionKeyArn during memory import Add encryptionKeyArn to CLI schema, MemoryDetail, and toMemorySpec so imported memories preserve their KMS encryption key configuration. Also update CDK L3 construct to pass encryptionKeyArn through to CfnMemory. * feat(import): capture executionRoleArn during memory import Map the API field memoryExecutionRoleArn to executionRoleArn in CLI schema to match the runtime convention. Also update CDK L3 construct to use an imported role via Role.fromRoleArn when executionRoleArn is provided instead of always creating a new one. * refactor(import): deduplicate actions.ts by reusing import-utils utilities actions.ts reimplemented 5 utilities that already exist in import-utils.ts. Replace local definitions with imports and use updateDeployedState() instead of inline state manipulation. Removed: sanitize(), toStackName(), fixPyprojectForSetuptools(), COPY_EXCLUDE_DIRS, copyDirRecursive() — all duplicates of import-utils.ts. * fix(import): paginate listings, auto-select single result, and preserve runtime config Three import bugs fixed: 1. listAgentRuntimes/listMemories only fetched one page (max 100). Added listAllAgentRuntimes/listAllMemories that paginate via nextToken. 2. Single-result listing incorrectly showed "Multiple found" error. Now auto-selects when exactly one runtime/memory exists. 3. toAgentEnvSpec dropped env vars, tags, lifecycle config, and request header allowlist. Extended AgentRuntimeDetail and getAgentRuntimeDetail to extract these fields (including ListTagsForResource call for tags), and mapped them in toAgentEnvSpec. Confidence: high Scope-risk: moderate Not-tested: pagination with >100 real resources (no integration test account available) * test(import): add tests for pagination, field extraction, auto-select, and env var mapping Tests cover: - listAllAgentRuntimes/listAllMemories pagination across multiple pages - getAgentRuntimeDetail extraction of environmentVariables, tags (via ListTagsForResource), lifecycleConfiguration, requestHeaderAllowlist - toAgentEnvSpec mapping of env vars Record to envVars array, plus direct mapping of tags, lifecycle config, and header allowlist - Single-result auto-select when listing returns exactly 1 runtime - Error cases: empty listings, multiple results, absent fields * feat(import): auto-create deployment target from ARN when none exist When no deployment targets are configured, import runtime/memory now parses the --arn to extract region and account, then creates a default target automatically instead of requiring `agentcore deploy` first. * fix(import): omit runtimeVersion for Container builds Container runtimes have no runtimeVersion from the API, but toAgentEnvSpec was hardcoding PYTHON_3_12 as a fallback. Now runtimeVersion is optional in the schema and only set for non-Container builds. * fix(import): filter API-internal namespace patterns from memory import Memory strategies like SUMMARIZATION and USER_PREFERENCE include auto-generated namespace patterns (e.g. /strategies/{memoryStrategyId}/...) that are API-internal and should not be written to local agentcore.json. Constraint: Only filters namespaces containing {memoryStrategyId} template var Rejected: Strip all namespaces for non-SEMANTIC strategies | would lose user-defined namespaces Confidence: high Scope-risk: narrow * fix(import): show project context error before --code flag validation Commander's requiredOption() for --code runs before the action handler, so users outside a project see "required option not specified" instead of "no agentcore project found". Change to option() so the handler's project context check (step 1) runs first. The --code validation at step 5 still catches missing values after project context is confirmed. Constraint: Commander validates requiredOption before action handlers execute Rejected: Moving project check into a Commander hook | adds complexity for one flag Confidence: high Scope-risk: narrow * fix(import): address bugbash issues for import commands - Invalid ARN now returns "Not a valid ARN" before target resolution - Failed imports roll back agentcore.json and clean up copied app/ dirs - Discovery listings show ARNs (not just IDs) so users can copy them - Remove --target flag from import runtime/memory subcommands - Add description field to AgentEnvSpec schema and wire through import Constraint: Commander validates requiredOption before action handlers Constraint: Rollback is best-effort to avoid masking the original error Rejected: Keep --target on subcommands | silently falls back to default, confusing UX Confidence: high Scope-risk: moderate * feat(import): add interactive TUI wizard for import command Adds a multi-screen TUI flow for importing runtimes, memories, and starter toolkit configs, replacing the silent fall-through that previously occurred when selecting "import" in the TUI. Constraint: onProgress must be injectable so TUI can display step progress Rejected: Single text-input screen for all flows | each import type has different required fields Confidence: high Scope-risk: narrow * fix(import): add early name validation and allow re-import with --name Bug 5: Validate --name against the AgentNameSchema regex before any file I/O operations. Previously, a malicious --name like '../../../etc/pwned' would copy files outside the project directory and set up a Python venv there before schema validation rejected it. Now invalid names are caught immediately with a clear error message. Applied to both import-runtime and import-memory. Bug 6: Allow re-importing the same cloud resource under a different local name when --name is provided. Previously, the deployed-state duplicate check blocked all re-imports by resource ID regardless of --name. Now it only blocks when --name is not provided, and suggests using --name in the error message. When --name is provided, it warns and proceeds. Applied to both import-runtime and import-memory. * revert(import): restore original duplicate-by-ARN blocking behavior Bug 6 is not a bug — blocking re-imports of the same cloud resource ARN is correct because allowing it would create duplicate CFN logical resources referencing the same physical resource, causing deploy failures. Reverts the --name re-import allowance while keeping the Bug 5 early name validation fix. * feat(import): mark import command as experimental * fix(import): wire deploy next-step navigation and show dotfiles in file picker Two TUI fixes for the import flow: 1. ImportFlow now accepts onNavigate prop so selecting "Deploy" from next steps navigates to the deploy screen instead of going back. 2. PathInput gains a showHidden prop; YamlPathScreen uses it so .bedrock_agentcore.yaml is visible in the file picker. * refactor(import): extract shared CDK import pipeline to eliminate duplication The three import handlers (import-runtime, import-memory, actions) all repeated the same CDK build/synth/bootstrap/publish/phase1/phase2/state-update pipeline (~120 lines each). Extract this into executeCdkImportPipeline() in a new import-pipeline.ts module. Also add resolveImportContext() and failResult() helpers to import-utils.ts for shared setup and error handling. Net effect: -335 lines, zero behavior change, all 260 tests pass. Constraint: Must not change any observable behavior — pure structural refactor Rejected: Full strategy-pattern abstraction | over-engineering for 2 concrete cases Confidence: high Scope-risk: moderate Not-tested: actions.ts YAML import path with real AWS (infra limitation) * fix(import): launch TUI wizard when running agentcore import with no args Previously `agentcore import` with no --source flag showed help text. Now it launches the interactive ImportFlow TUI, matching the pattern used by `agentcore add` and other commands. * fix(import): wire deploy and status navigation from CLI-inline TUI When running `agentcore import` from CLI (not full TUI), selecting "deploy" or "status" from the next-steps menu now renders the corresponding screen instead of silently exiting. * style(import): fix prettier formatting in TUI screens * fix(security): update lodash and lodash-es to resolve high-severity vulnerabilities npm audit fix resolves CVE for code injection via _.template and prototype pollution via _.unset/_.omit in lodash <=4.17.23. * refactor(aws): extract createControlClient to avoid per-call client instantiation Each function in agentcore-control.ts was creating a new BedrockAgentCoreControlClient on every call, wasting HTTP connections and credential resolution. Extracted a shared createControlClient() factory and reuse a single client across paginated listAll* calls. * fix(import): log warnings on silent catch failures instead of swallowing errors Tag fetch failures in agentcore-control.ts and rollback failures in import-runtime.ts and import-memory.ts were silently swallowed. Users had no indication when config could be left in a broken state. Added console.warn calls matching the existing pattern in bedrock-import.ts. --------- Co-authored-by: Aidan Daly * fix(ci): regenerate lockfile for npm 11 compatibility (#770) npm 11 (shipped with Node 24.x) requires all optional dependency entries in the lock file, even for non-matching platforms. The lock file was generated with npm 10, which only records the current platform's optional deps (@esbuild/linux-x64). This caused npm ci to fail on the Node 24.x CI matrix entry with "Missing: @esbuild/* from lock file" errors. Regenerated with npm 11 to include all 26 @esbuild/* platform entries. Constraint: Lock file must be compatible with npm 10 (Node 20/22) and npm 11 (Node 24) Rejected: Pin CI to npm 10 | would mask the issue and delay migration Confidence: high Scope-risk: narrow Co-authored-by: Claude Opus 4.6 * ci: block schema changes in PRs (#712) The JSON schema is served live from the repo, so any commit to main that modifies it is effectively a release. Only the release workflow should regenerate and commit schema changes. * chore: bump version to 0.6.0 (#771) Co-authored-by: github-actions[bot] * fix: make add command description consistent with remove (#773) * chore(deps): bump vite from 8.0.3 to 8.0.5 (#777) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.0.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(fetch): add --identity-name option for custom credential lookup (#715) (#774) The `fetch access` command hardcoded credential lookup to `-oauth` via `computeManagedOAuthCredentialName()`, causing failures when users create identities with custom names. This adds an `--identity-name` option that lets users specify which credential to use for OAuth token fetch, falling back to the default convention when omitted. When no matching credential is found, the error message now lists all available OAuth credentials and suggests using `--identity-name`. Constraint: Must remain backward compatible — omitting --identity-name preserves existing behavior Rejected: Modify computeManagedOAuthCredentialName globally | would break other consumers Confidence: high Scope-risk: narrow Not-tested: TUI interactive flow and invoke command auto-fetch paths (noted as follow-up) * feat(status): display runtime invocation URL for deployed agents (#775) Show the runtime invocation URL in agentcore status output for each deployed agent. The URL is computed from the runtime ARN and target region, and displayed in CLI text output, JSON output, and the TUI ResourceGraph component. URL format: https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{encodedArn}/invocations Closes #716 Constraint: URL is only available when both targetConfig and runtimeArn exist Rejected: Reuse existing buildInvokeUrl from agentcore.ts | includes ?qualifier=DEFAULT which is for API invocation, not display Confidence: high Scope-risk: narrow * feat(create): add --skip-install flag to skip dependency installation (#782) Adds a --skip-install flag to `agentcore create` that skips all dependency installation (npm install for CDK and uv sync for Python). This enables enterprise users behind corporate proxies or private registries to modify package.json/pyproject.toml before installing dependencies manually. The flag sets the existing AGENTCORE_SKIP_INSTALL env var (previously only used in tests) and also implies --skip-python-setup behavior. A post-create message instructs users to install manually. * feat(import): add evaluator and online eval config import subcommands (#780) * feat(import): add evaluator import subcommand with TUI wizard Add `agentcore import evaluator` to import existing AWS evaluators into CLI projects. Refactor import types and utilities for extensibility so future resource types require minimal new code. Changes: - Add import-evaluator.ts handler with toEvaluatorSpec mapping (LLM-as-a-Judge and code-based evaluators), duplicate detection, and CDK import pipeline - Enhance getEvaluator API wrapper to extract full evaluatorConfig (model, instructions, ratingScale) and tags from SDK tagged unions - Add listAllEvaluators pagination helper filtering out built-in evaluators - Widen ImportableResourceType union and shared utilities for evaluator support - Add evaluator to TUI import flow (select, ARN input, progress screens) - Add 17 unit tests covering spec conversion, template lookup, and error cases Tested end-to-end against real AWS evaluator (bugbash_eval_1775226567-zrDxm7Gpcw) with verified field mapping for all config fields, tags, and deployed state. * fix(import): use correct importType for evaluator in TUI flow The TUI import wizard hardcoded importType as 'memory' for all non-runtime resources, causing evaluator imports to fail with "ARN resource type evaluator does not match expected type memory". Use flow.resourceType instead so the correct handler is dispatched. * feat(import): add online eval config import subcommand Add `agentcore import online-eval` to import existing online evaluation configs from AWS into CLI-managed projects. Follows the same pattern as runtime, memory, and evaluator imports. The command extracts the agent reference from the config's service names (pattern: {agentName}.DEFAULT), maps evaluator IDs to local names or ARN fallbacks, and runs the full CDK import pipeline. Also removes incorrect project-prefix stripping from evaluator and runtime imports — imported resources come from outside the project and won't have the project prefix. Constraint: Agent must exist in project runtimes[] before import (schema enforces cross-reference) Constraint: Evaluators not in project fall back to ARN format to bypass schema validation Rejected: Loose agent validation | schema writeProjectSpec() enforces runtimes[] cross-reference Confidence: high Scope-risk: moderate * feat(import): add online eval config to TUI import wizard Add 'Online Eval Config' option to the interactive import flow so users can import online evaluation configs via the TUI, not just the CLI. Follows the same ARN-only pattern as evaluator and memory imports: select type → enter ARN → import progress → success/error. * docs: add TUI import wizard screenshots for online eval Screenshots captured from the TUI import flow showing: - Import type selection menu with Online Eval Config option - ARN input screen for online eval config - ARN input with a real config ARN filled in * Revert "docs: add TUI import wizard screenshots for online eval" This reverts commit cb4c6757e66ffefe05c974d44e34754cff216196. * refactor(import): extract generic import orchestrator with descriptor pattern Reduce ~1,400 lines of duplicated orchestration across four import handlers (runtime, memory, evaluator, online-eval) to ~600 lines by extracting shared logic into executeResourceImport(). Each resource type now provides a thin descriptor declaring its specific behavior. Constraint: Public handleImport* function signatures unchanged (TUI depends on them) Constraint: Factory functions needed for runtime/online-eval to share mutable state between hooks Rejected: Strategy class hierarchy | descriptor objects are simpler and more composable Confidence: high Scope-risk: moderate * refactor(aws): extract paginateAll and fetchTags helpers in agentcore-control Deduplicates identical pagination loops across 4 listAll* functions and identical tag-fetching try/catch blocks across 3 getDetail functions. Also adds optional client param to listEvaluators and listOnlineEvaluationConfigs for connection reuse during pagination. Addresses deferred review feedback from PR #763. Constraint: evaluator listAll still filters out Builtin.* entries Confidence: high Scope-risk: narrow * fix(import): resolve evaluator references via deployed state for imported evaluators resolveEvaluatorReferences used string-contains matching (evaluatorId.includes(localName)) which only works when the evaluator was deployed by the same project. Imported evaluators with renamed local names never matched, falling back to raw ARNs in the config. Now reads deployed-state.json to build an evaluatorId → localName reverse map and checks it first, before the string-contains heuristic. Constraint: Deployed state may not exist yet (first import) — .catch() handles gracefully Rejected: Passing deployed state through descriptor interface | only online-eval needs this Confidence: high Scope-risk: narrow * fix(import): auto-disable online eval configs to unlock evaluators during import Evaluators referenced by ENABLED online eval configs are locked by the service (lockedForModification=true), causing CFN import to fail when it tries to apply stack-level tags. Now the evaluator import detects the lock, temporarily disables referencing online eval configs, performs the import, then re-enables them. Constraint: Re-enable runs in finally block so configs are restored on both success and failure Constraint: Only disables configs that actually reference this specific evaluator Rejected: Refuse import with manual guidance | user can't pause configs not yet in project Confidence: high Scope-risk: moderate * Revert "fix(import): auto-disable online eval configs to unlock evaluators during import" This reverts commit 583939153e336a72c6e5cd425dd02a834d73b9d0. * fix(import): block evaluator import when referenced by online eval, use ARN-only references Evaluators locked by an online eval config cannot be CFN-imported because CloudFormation triggers a post-import TagResource call that the resource handler rejects. Instead of stripping tags from the import template, block the import with a clear error and suggestion to use import online-eval. Online eval config import now always references evaluators by ARN rather than resolving to local names, since the evaluators cannot be imported into the project alongside the config. Constraint: CFN IMPORT triggers TagResource which fails on locked evaluators Rejected: Strip Tags from import template | still fails on some resource types Confidence: high Scope-risk: narrow * fix(import): resolve OEC agent reference via deployed state when runtime has custom name extractAgentName() derives the AWS runtime name from the OEC service name pattern, but this fails to match when the runtime was imported with --name since the project spec stores the local name. Now falls back to listing runtimes to find the runtime ID, then looks up the local name in deployed-state.json. * fix(import): strip CDK project prefix from OEC service name when resolving agent CDK constructs set the OEC service name as "{projectName}_{agentName}.DEFAULT". extractAgentName() strips ".DEFAULT" but not the project prefix, so the lookup fails against local runtime names. Now strips the prefix as a fast path before falling back to the deployed-state API lookup. * fix(import): show friendly error for non-existent evaluator ID getEvaluator() now catches ResourceNotFoundException and ValidationException from the SDK and rethrows a clear message instead of exposing the raw regex validation error. * fix(import): validate ARN resource type for online-eval import import online-eval used a naive regex to extract the config ID from the ARN, skipping resource type, region, and account validation. Now uses parseAndValidateArn like all other import commands. Added an ARN resource type mapping to handle the online-eval vs online-evaluation-config mismatch between ImportableResourceType and the ARN format. * refactor(import): address PR review feedback - Add `red` to ANSI constants, replace inline escape codes - Type GetEvaluatorResult.level as EvaluationLevel at boundary - Combine ARN_RESOURCE_TYPE_MAP, collectionKeyMap, idFieldMap into single RESOURCE_TYPE_CONFIG to prevent drift - Export IMPORTABLE_RESOURCES as const array, derive type from it, replace || chains with .includes() - Fix samplingPercentage === 0 false positive (use == null) - Document closure state sequencing contract on descriptor hooks * test(import): remove unreachable empty-level evaluator test The test exercised a defensive fallback in toEvaluatorSpec for an empty level string, but now that GetEvaluatorResult.level is typed as EvaluationLevel, the boundary cast in getEvaluator prevents this case from ever reaching toEvaluatorSpec. * feat: add custom dockerfile support for Container agent builds (#783) * feat: add custom dockerfile support for Container agent builds Add an optional `dockerfile` field to Container agent configuration, allowing users to specify a custom Dockerfile name (e.g. Dockerfile.gpu) instead of the default "Dockerfile". Changes across all layers: - Schema: Add dockerfile field to AgentEnvSpecSchema with filename validation - CLI wizard: Add "Custom Dockerfile" option to Advanced settings multi-select, with dedicated Dockerfile input step in the breadcrumb wizard - Dev server: Thread dockerfile through container config to docker build - Deploy preflight: Validate custom dockerfile exists before deploy - Packaging: Pass dockerfile to container build commands - Security: getDockerfilePath rejects path traversal (/, \, ..) - Tests: 64 new/updated tests across schema, preflight, dev config, packaging, wizard, and constants Constraint: Dockerfile must be a filename only (no path separators) Rejected: Accept full paths | path traversal security risk Rejected: Auto-copy Dockerfile on create | users manage their own Dockerfiles Confidence: high Scope-risk: moderate Not-tested: Interactive TUI tested manually via TUI harness (not in CI) * chore: remove dead code and redundant tests from dockerfile PR - Remove unused ADVANCED_GROUP_LABELS constant (dead code) - Remove unnecessary export on DOCKERFILE_NAME_REGEX - Fix stale `steps` dependency in useGenerateWizard setAdvanced callback - Trim computeByoSteps.test.ts to dockerfile-only tests (remove 11 tests for pre-existing behavior unchanged by this PR) - Remove redundant "uses default Dockerfile" tests that duplicate existing coverage in preflight, config, and container packager test files - Consolidate shell metacharacter it.each from 5 cases to 1 representative Confidence: high Scope-risk: narrow * fix: skip template Dockerfile scaffolding when custom dockerfile is set When a custom dockerfile is configured (e.g. Dockerfile.gpu), the renderer was still copying the default template Dockerfile into the agent directory, leaving an unused file alongside the custom one. Thread the dockerfile config through AgentRenderConfig and use a new exclude option on copyAndRenderDir to skip the template Dockerfile when a custom one is specified. The .dockerignore is still scaffolded. Constraint: copyAndRenderDir is a shared utility used by all renderers Rejected: Delete template after render | user requested option A (don't create) Confidence: high Scope-risk: narrow * fix: use PathInput file picker for Dockerfile selection in TUI Replace the TextInput with PathInput for Dockerfile selection in both the BYO add-agent and Generate wizard flows. This gives users a real file browser with directory navigation and existence validation on submit, matching the UX pattern used by the policy file picker. BYO flow: PathInput scoped to the agent's code directory so users browse their existing files and pick a Dockerfile. Generate flow: PathInput scoped to cwd so users browse the filesystem to find a Dockerfile to copy into the new project. Added allowEmpty and emptyHelpText props to PathInput so users can press Enter to use the default Dockerfile. Constraint: PathInput is a shared component used by policy and import screens Rejected: Soft warning on TextInput | user preferred real file picker like policy Confidence: high Scope-risk: narrow * feat(invoke,dev): add exec mode for running shell commands in runtimes (#750) * feat(invoke,dev): add exec mode for running shell commands in runtimes Add ! exec mode to invoke TUI for running shell commands in deployed runtimes, and ! local exec / !! container exec to dev TUI. Includes non-interactive --exec flag for both invoke and dev commands. - invoke TUI: type ! to enter exec mode, runs commands in deployed runtime - invoke CLI: --exec flag runs commands via InvokeAgentRuntimeCommand API - dev TUI: type ! for local exec, !! for container exec (Container builds) - dev CLI: --exec flag execs into running dev container (Container only) - TextInput: add onChange and onBackspaceEmpty props for mode switching - Pink/magenta hint text appears when exec input is empty, disappears on typing - Backspace on empty input reverses mode (!! -> ! -> normal) - IAM policy and docs updated with new bedrock-agentcore:InvokeRuntimeCommand Constraint: dev --exec CLI is Container-only since CodeZip users have local terminal Rejected: Ctrl+E hotkey for container exec | !! double-bang is more discoverable and consistent Confidence: high Scope-risk: moderate * fix: address review findings — side effects, DRY, dead code, validation CRITICAL: Move onBackspaceEmpty callback outside setState updater (React purity violation) and add text.length === 0 guard so it only fires when input is truly empty. HIGH: Extract shared runSpawnCommand helper in useDevServer to DRY up execCommand/execInContainer near-duplication (~100 lines → ~60 lines). Deslop: Remove dead providerInfo/modelProvider code paths, simplify singleValueStream to plain yield, replace options.prompt! assertions with early guard, remove redundant comments. Medium: Fix timeout:0 falsy check, add --exec+--stream validation, handle undefined exitCode explicitly, add SDK cast comment + runtime guard. Constraint: onBackspaceEmpty must fire outside setState to avoid double-fire in React 18 concurrent mode Rejected: Checking state inside updater for callback | React purity violation Confidence: high Scope-risk: narrow * feat(tui): sticky exec mode with distinct visual styling - Exec prompt (!) now uses magenta instead of yellow for clear differentiation from chat prompt (>) - Exec output renders in default terminal foreground, distinct from green chat responses — works on both dark and light terminals - Exec mode is sticky: ! prompt persists after running a command, exit via Escape or Backspace-on-empty - Conversation rendering upgraded to per-line colored output in InvokeScreen (matching DevScreen pattern) Constraint: Terminal color palette limited to 8 base colors Rejected: cyan for exec output | too similar to blue chat input Rejected: yellow for exec output | invisible on light terminal backgrounds Confidence: high Scope-risk: narrow * fix(tui): resolve 3 exec mode UX bugs from review feedback Bug 1: Escape in exec mode no longer exits the app. The Screen component's useExitHandler is disabled when in input mode (exitEnabled={mode !== 'input'}), so only TextInput's onCancel handles Escape. Escape in exec drops to > prompt; Escape from > goes to chat mode; Escape from chat exits. Bug 2: Dim prompt during command execution now shows ! or !! instead of always showing >, matching the current exec state. Bug 3: execInputEmpty is reset to true after command execution, so typing ! in sticky exec mode correctly escalates to !! (container exec) on container agents. * fix(tui): eliminate command flash during exec mode transitions The !! command and prompt would briefly vanish (replaced by >) when executing a container command, because setMode('chat') fired in a separate render batch from setConversation/setIsStreaming. Fix: add onStart callback to runSpawnCommand that fires in the same synchronous block as the conversation/streaming state updates, so React batches them together. The mode transition and conversation update now render atomically — no flash. * chore: bump version to 0.7.0 (#784) Co-authored-by: github-actions[bot] * fix(ci): pin npm version to avoid self-upgrade corruption (#785) npm install -g npm@latest fails on GitHub Actions runners when npm tries to replace its own modules mid-installation, corrupting the promise-retry dependency. Pin to npm@11.5.1 which is the minimum version needed for OIDC trusted publishing. * chore: bump version to 0.7.1 (#786) Co-authored-by: github-actions[bot] * fix: evo cleanup — 6 fixes for config bundles, recommendations, and batch eval 1. Remove "List Recommendations" from TUI hub (unimplemented API call) 2. Config bundle TUI hub reads from project config (agentcore.json) instead of listing all bundles from the API 3. Default config bundle branch name changed from "mainline" to "main" 4. Tool description recommendation: remove evaluator step (API does not accept evaluators); System prompt recommendation: single-select evaluator (exactly 1 required) 5. Tool description TUI: remove duplicate inputSource/content steps (tools step already collects tool name:description pairs) 6. Batch eval: only send executionRoleArn when explicitly provided via --execution-role flag (FAS creds work without it) * fix: standardize CLI flags, error handling, and batch eval TUI - Rename --agent to --runtime in batch eval and recommendation commands - Rename --days to --lookback in run eval command - Make --evaluator optional for tool-description recommendations (matches API) - Standardize evaluator flag descriptions across all run subcommands - Make deleteConfigurationBundle throw instead of returning {success, error} - Remove requestId from recommendation API error messages - Silence non-fatal tag fetch warnings in agentcore-control - Add StepProgress component to batch eval TUI with elapsed timer * feat: surface batch eval evaluatorSummaries and session stats - Add EvaluatorSummary and EvaluationResults types to batch eval API client - Extract evaluationResults from GetBatchEvaluation API response - CLI and TUI prefer API-provided evaluatorSummaries (averageScore, totalEvaluated, totalFailed) over local computation when available - Show session stats (total, completed, failed) in both CLI and TUI - Persist evaluationResults in saved batch eval records - Falls back to local CloudWatch-based aggregation when API summaries are not yet available (gamma currently returns session counts only; evaluatorSummaries is in beta and will roll forward) * docs: improve CLI help text, descriptions, and examples - Fix `run` parent description (was eval-only, now covers all 3 subcommands) - Fix `--agent-arn` reference in copy.ts (renamed to --runtime-arn) - Fix logs example using --agent (now --runtime) - Improve `evals` and `recommendations` descriptions for clarity - Add ground truth context to -A, --expected-trajectory, --expected-response - Improve recommendation option descriptions (--inline, --tools, --bundle-name) - Clarify --lookback as "How far back to search for traces in CloudWatch" - Clarify --evaluator as "required for system-prompt, optional for tool-description" - Remove "temporary — will be removed" from --execution-role - Improve batch eval description to mention CloudWatch sessions - Add CLI_ONLY_EXAMPLES for run eval, run batch-evaluation, run recommendation, config-bundle, stop, and evals commands --------- Signed-off-by: dependabot[bot] Co-authored-by: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Co-authored-by: Trirmadura J Ariyawansa Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Co-authored-by: Aidan Daly Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> * fix(ci): exclude .github/workflows/ from public repo sync (#54) GITHUB_TOKEN lacks the 'workflows' permission, so pushing workflow file changes from the public repo causes the sync to fail. Use --no-commit --no-ff and restore .github/workflows/ from HEAD before committing, in both the clean merge and conflict paths. * fix: evaluator resolution + config bundle filter field name (#55) * fix: resolve custom evaluator names to deployed IDs in batch eval Batch eval was sending project-level evaluator names (e.g. "MyCustomEval") instead of deployed evaluator IDs (e.g. "cbtest_MyCustomEval-xv2w2e42GL"). Builtin.* evaluators worked because the service resolves them directly, but custom evaluators need the deployed ID. Adds evaluator name resolution from deployed state, matching how run eval already resolves custom evaluator names in run-eval.ts. * fix: correct config bundle version filter field name to match API The ListConfigurationBundleVersions API expects `createdByName` (string) in the version filter, but the CLI was sending `createdBy` (string[]). This caused the filter to be silently ignored by the API. * fix: tool description comma parsing in recommendations (#56) CLI --tools option changed from comma-separated string to variadic (--tools "search:desc" --tools "calc:desc") so commas in descriptions don't break parsing. TUI uses a smarter regex split that only splits on commas followed by a valid tool name pattern and colon. * feat: add A/B test CLI support - ABTestPrimitive with add/remove following config bundle pattern - SigV4-signed HTTP client for AgentCore Evaluation Data Plane API - Post-deploy reconciliation creates/deletes AB tests + IAM roles - TUI: add wizard, remove flow, compact dashboard detail screen - CLI commands: add, remove, pause, resume, stop, view details - Auto-creates project-scoped IAM role with least-privilege permissions - Post-deploy warnings surfaced in TUI deploy screen - Comprehensive unit, hook, and integration tests * fix: route config-bundle to interactive TUI instead of CLI-only help screen config-bundle was listed in CLI_ONLY_EXAMPLES which intercepted the command before it could reach the ConfigBundleFlow TUI route, showing a static usage screen instead of the interactive bundle hub. * feat: add A/B test CLI support (#50) - ABTestPrimitive with add/remove following config bundle pattern - SigV4-signed HTTP client for AgentCore Evaluation Data Plane API - Post-deploy reconciliation creates/deletes AB tests + IAM roles - TUI: add wizard, remove flow, compact dashboard detail screen - CLI commands: add, remove, pause, resume, stop, view details - Auto-creates project-scoped IAM role with least-privilege permissions - Post-deploy warnings surfaced in TUI deploy screen - Comprehensive unit, hook, and integration tests * fix: TUI bug fixes + config bundle recommendation support (#57) * fix: batch eval name allows blank + evaluator list scrollable - Add allowEmpty to batch eval name TextInput so users can leave it blank for auto-generated names (placeholder said "leave blank" but validation blocked empty input) - Add maxVisibleItems={10} to evaluator WizardSelect/WizardMultiSelect in both recommendation and batch eval flows to prevent list overflow when many evaluators exist (enables arrow-key scrolling) * feat: add config bundle selection to recommendation TUI wizard Add 'Config bundle' as a third input source option in the recommendation TUI wizard (alongside 'Enter inline' and 'Load from file'). When selected, users can pick from deployed config bundles to use their system prompt as the recommendation input. - Add ConfigBundleItem type and 'bundle' step to wizard flow - Load deployed config bundles during initialization - Pass bundleName/bundleVersion to runRecommendationCommand - Only show config-bundle option when bundles are deployed * fix: config bundle recommendation uses local system prompt instead of broken API path The systemPromptJsonPath field in the config bundle API is broken server-side (all JSON path formats resolve to empty). Work around this by reading the system prompt from the local agentcore.json project config and passing it as inline text to the recommendation API. Also adds config bundle as an always-visible input source option in the recommendation TUI wizard, with proper empty-state handling when no bundles are deployed. * fix: single evaluator + config bundle field selection for recommendations (#61) * fix: enforce single evaluator for recommendations + bundle field selection - Recommendation API accepts exactly one evaluator for system-prompt (min:1, max:1) and none for tool-description. CLI flag changed from variadic --evaluator to singular --evaluator . Operations layer validates count before hitting API. - Config bundle recommendation now lets users pick which configuration field contains the system prompt, instead of guessing key names. New "Prompt Field" wizard step shows all string fields from the selected bundle's configuration. - Validates system prompt content is non-empty before API call, preventing the text:"" → 400 error reported by testers. * feat: add config bundle support for tool description recommendations - TUI wizard now shows config-bundle as input source for tool descriptions - bundleField (singular) → bundleFields (array) to support multi-select - System prompt: single-select picks one field as prompt content - Tool description: multi-select picks fields as toolName:description pairs - Fix confirm screen to display bundleFields correctly - Skip "Tools" confirm field when using config-bundle input * fix: use single-select for bundle field when only one field available - Tool desc with 1 field: WizardSelect (Enter to pick) - Tool desc with 2+ fields: WizardMultiSelect (Space to toggle) - Footer shows "Space to select" hint for multi-select mode * feat: add lookback days and session selection to batch evaluation - AWS client: add sessionInput (sessionIds + sessionFilterConfig) to CloudWatchSource - Operations: accept sessionIds and lookbackDays, compute time range, pass to API - CLI: add -d/--lookback-days and -s/--session-ids options for batch-evaluation - TUI: add lookback days input and session discovery/multi-select to batch eval wizard - Fix nit: remove "API accepts" mention from evaluator help text * fix: send sessionIds or sessionFilterConfig, not both API requires either sessionIds OR sessionFilterConfig in cloudWatchSource.sessionInput. When sessionIds are provided (from session picker), skip the time filter. * feat: batch eval ground truth + config bundle & status fixes (#62) * feat: ground truth support for batch evaluation (CLI + TUI) - Add sessionMetadata types (assertions, expected trajectory, turns) - Add --ground-truth/-g CLI flag to load ground truth from JSON file - Add TUI ground truth step with skip/file/inline options - Use PathInput file picker for ground truth file selection - Fix evaluator ARN resolution (extract short name from full ARN) - Merge session IDs from ground truth metadata with wizard selection - Fix PathInput Enter key to auto-select highlighted dropdown item - Fix TUI wizard Esc to go back one step instead of exiting * fix: display config bundles and ab tests in CLI status command The status command was computing statuses for config-bundle and ab-test resources but never rendering them. Also fixes empty target name display. * fix: resolve stale config bundle IDs with API fallback When config bundles are recreated, deployed-state.json retains old bundle IDs that no longer exist. Both CLI and TUI now fall back to the list API when the deployed-state bundleId returns a 404, fixing the "no differences found" issue in config bundle diff. * fix: batch eval uses deployed region from aws-targets Instead of relying solely on detectRegion() (which defaults to us-east-1), batch evaluation now reads the region from aws-targets.json first. This ensures it runs against the region where the agent is actually deployed. Applies to both CLI and TUI flows. * feat: add placeholder and format hint for config bundle components input Shows expected JSON format (component ARN → configuration map) as a hint above the inline input and as placeholder text. Also adds placeholder for file path input. * fix: improve config-bundle --help to clarify bundle name usage Clarifies that --bundle takes the bundle name from agentcore.json (not the AWS bundle ID), and that --from/--to version IDs come from the versions subcommand output. * fix: pass branchName to getConfigurationBundle API calls The GetConfigurationBundle API now requires a branchName query parameter. Without it, the API returns 404 "Configuration bundle branch not found". This was causing config-bundle versions/diff commands to fail even after the stale ID fallback resolved the correct bundle ID. * fix: show log file path on failure and saved results path on success For batch eval and recommendation commands (both CLI and TUI): - On failure: show the log file path so users can debug - On success: show the saved results file path (recommendation CLI was missing this; batch eval TUI now shows it too) * feat: AB test HTTP gateway, trace delivery, and TUI improvements (#63) * feat: add stop commands and Esc-to-stop for batch eval & recommendation (#65) * fix: always show gateway step in AB test wizard instead of silently skipping (#67) When no existing gateways are available, the wizard silently skipped the gateway step, leaving customers unaware that a gateway would be auto-created. Now the step always shows with "Create new HTTP gateway" as the only option, making the auto-creation explicit and visible. * feat: bundle Python SDK wheel into CLI for offline install - Add src/assets/wheels/ directory for bundled Python wheels - Add --find-links to uv pip install args (async + sync) to prefer local wheels over PyPI when present - Add wheel copy step to bundle.mjs for redundant safety - Include bedrock_agentcore-1.6.0 wheel from feat/evo_main branch When the CLI is installed from a bundle tarball, agentcore deploy will automatically use the bundled SDK wheel instead of fetching from PyPI, enabling fully offline preview distributions. * Fix __dirname in ESM bundle, update versions for evo-private-beta - Add __dirname/__filename shims to esbuild banner so bundled ESM CLI can resolve paths correctly (fixes ReferenceError at runtime) - Fix BUNDLED_WHEELS_DIR path: ../assets/wheels from dist/cli/ instead of ../../assets/wheels which resolved to the wrong directory - Bump CLI version to 0.8.0-evo-private-beta to avoid collision with public 0.8.0 release - Replace Python SDK wheel with 1.6.0.dev20260410 built from feat/evo_main branch of bedrock-agentcore-sdk-python-private * chore: update CLI version to 0.8.0-evo-private-beta-20260410 * fix: invocation URL in AB test views and listHttpGatewayTargets parsing (#68) * fix: show full invocation URL in AB test views and fix listHttpGatewayTargets parsing - Remove gateway URL from agent status view (only show for AB tests) - Rename 'Gateway URL' to 'Invocation URL' across AB test views - Fix listHttpGatewayTargets to parse 'items' key from API response - Fetch target name in AB test detail screen for full invocation URL - Add unit tests for listHttpGatewayTargets * chore: remove overwritten test file * fix: resolve config bundle ARN and recommendation improvements (#71) * fix: resolve config bundle ARN from deployed state for recommendations The recommendation CLI command was passing the human-readable bundle name (e.g. "MyBundle") as the bundleArn field in the API request instead of the actual ARN. This caused a 400 validation error from the API. Now resolves the bundle ARN from deployed state when using --bundle-name, and passes ARN values through directly (for TUI which already stores ARNs). Also includes: - Config bundle support in deploy preflight and CDK templates - JSONPath dot notation for config bundle field resolution - Session ID auto-fetch for all recommendation types (not just tool-desc) - Conditional tool explanation display (hide when empty) - CLI progress output for non-TUI recommendation command - Request IDs included in API error messages for debugging - Lint fixes in preflight.ts (type safety, deduplicated imports) * feat: auto-apply recommendation results to config bundles When recommendations use a config bundle as input source, automatically apply the results back to agentcore.json. CLI auto-applies after display, TUI offers an "Apply to config bundle" action. Supports both system prompt and tool description recommendations via JSONPath resolution. * refactor: sync config bundle from server instead of local apply The server automatically creates a new config bundle version when a recommendation uses a config bundle as input. Instead of locally parsing JSONPath and writing recommended text, fetch the server-created version via GetConfigurationBundleVersion and sync local agentcore.json + deployed state to match. * fix: hide empty explanation in recommendation results Check for non-empty explanation before displaying in both CLI and TUI. The API sometimes returns an empty string for explanation fields. * fix: remove hardcoded branchName 'main' in config bundle resolution resolveBundleByName was passing branchName: 'main' to GetConfigurationBundle, causing 404 errors for bundles on other branches (mainline, experiment, etc.). Omit branchName so the API returns the latest version regardless of branch. * feat: simplify add config bundle TUI with component picker Replace raw JSON/ARN input with a guided flow: select component type (runtime/gateway), pick from deployed resources, enter just the config values. Supports adding multiple components via "Add another?" loop. * fix: remove incorrect CW unsupported disclaimer for tool-desc recommendations CloudWatch trace source is supported for tool description recommendations. Remove the misleading note that said otherwise. * feat: support CloudWatch logs trace source for tool-desc recommendations * feat: add create config bundle option in AB test variant picker * feat: add batch eval history screen and duration hint - New BatchEvalHistoryScreen in evals hub for viewing past batch eval results - Add "Run Batch Evaluation" and "Batch Eval History" to evals hub - Add "This may take a few minutes..." message in CLI and TUI during batch eval * fix: config bundle Esc on componentType goes back instead of addAnother Pressing Esc on the component type step was incorrectly navigating to the "add another?" step when components already existed. Now it always goes to the previous step (description) as expected. * fix: resolve {{agent:name}} placeholder in config bundle component keys The resolveComponentKey function handled {{runtime:name}} and {{gateway:name}} but not {{agent:name}}, which is the documented… * fix: remove dead preflight patch and use proper teardown for evo resources (#1072) - Preflight: remove config bundle type patching entirely — the CDK schema does not have a type field, so neither the on-disk write (original) nor the in-memory patch was needed - Teardown: delegate to deleteOrphanedABTests and deleteOrphanedHttpGateways instead of raw deleteABTest/deleteHttpGatewayWithTargets — reuses stop/poll/delete/role-cleanup logic. Correct ordering: AB tests first (they create rules on gateways), then gateways, then config bundles. Closes #1070 * fix: remove dead preflight patch, proper teardown, optional evo schema fields (#1073) - Preflight: remove config bundle type patching entirely — the CDK schema does not have a type field, so the patch was dead code - Teardown: delegate to deleteOrphanedABTests and deleteOrphanedHttpGateways instead of raw API calls — reuses stop/poll/delete/role-cleanup logic. Correct ordering: AB tests first (they create rules on gateways), then gateways, then config bundles. - Schema: change configBundles, abTests, httpGateways from .default([]) to .optional() so they don't appear in agentcore.json unless the user opts into those preview features. All access sites updated with ?? [] or ??= guards. Closes #1070 * fix: revert .optional() to .default([]) and strip empty evo arrays on write (#1074) The .optional() change from #1073 breaks Zod JSON Schema generation ("Undefined cannot be represented in JSON Schema"). Revert to .default([]) so schema generation and parsing work, but strip empty configBundles/abTests/httpGateways in writeProjectSpec before writing to disk so preview features don't pollute agentcore.json. * fix: remove unnecessary non-null assertions after .default([]) revert (#1075) * chore: bump version to 0.13.0 (#1076) Co-authored-by: github-actions[bot] * docs: remove CrewAI from supported frameworks (#1059) CrewAI is not implemented as a framework option in the CLI — it does not exist in SDKFrameworkSchema, has no template assets, and is not in the SDK_MODEL_PROVIDER_MATRIX. Remove it from README and docs/frameworks.md to match the actual CLI capabilities. Constraint: Only frameworks in src/schema/constants.ts SDKFrameworkSchema are valid Rejected: Mark CrewAI as "BYO only" | no special BYO treatment exists for it Confidence: high Scope-risk: narrow * chore(deps): override glob to ^13 to silence install deprecation warning (#1008) Co-authored-by: minorun365 * fix: address formatting failure in docs (#1080) * test: remove http-gateway-targets e2e test (#1090) HTTP gateways are created through the AB test flow (add ab-test --mode target-based), not via agentcore add gateway. The standalone test was using MCP gateway commands against placeholder URLs that caused AWS::BedrockAgentCore::GatewayTarget CREATE_FAILED errors. HTTP gateway coverage is provided by ab-test-target-based.test.ts. Co-authored-by: Claude Sonnet 4.6 (1M context) * fix: sync e2e IAM policy and fix run eval flag (#1092) * fix: align E2E batch eval and recommendation tests with current API (#1093) * fix: use correct resourceType for config bundle in E2E status test (#1094) The status command outputs resourceType as 'config-bundle' (hyphenated) but the test was checking for 'configBundle' (camelCase). * docs: clarify integration vs e2e test boundaries and add e2e README (#1111) * docs: clarify integration vs e2e test boundaries and add e2e README - Rewrite integ-tests/README.md to accurately describe what integration tests do (local file/stdout assertions, no AWS required) — the old README described e2e behavior (CloudFormation, credentials, costs) - Add e2e-tests/README.md documenting the AWS boundary, prerequisites, createE2ESuite() pattern, key patterns, and cleanup requirements - Update docs/TESTING.md to add an E2E Tests section with a link to the new README, fix the stale integration tests section that incorrectly listed AWS credentials as a prerequisite, and add link to integ README * chore: fix prettier formatting in docs and READMEs * feat: add archive command for batch evaluations and recommendations (#1112) * feat: add archive command for batch evaluations and recommendations. Introduces a new top-level archive command with two subcommands: archive batch-evaluation and archive recommendation. Each calls the corresponding service delete API and removes the matching local .cli/ history file. * fix: prefix HTTP gateway names with project name to prevent cross-project collisions (#1105) * test: collapse schema enumeration tests and remove duplicates (#1087) * test: collapse schema enumeration tests and remove duplicate help text tests Collapse it.each per-value enumeration patterns into consolidated tests across 9 schema test files. Each enum value was tested individually (e.g., 4 separate 'accepts SEMANTIC', 'accepts SUMMARIZATION' tests) -- now represented by 1-2 tests with representative values. Remove deploy/dev --help unit tests (covered by integ-tests/help.test.ts) and 5 it.todo placeholders from credential-ops.test.ts. Schema tests: ~506 -> ~415. All cross-field validation, superRefine, boundary, and discriminated union tests preserved. * fix: address review comments — restore help tests and add .options assertions - Restore deploy --help and dev --help flag-level assertions (integ smoke test only checks exit code and "Usage:", not specific flags) - Restore --invoke negative regression guard in dev.test.ts - Replace representative-sample enum tests with .options assertions for AgentCoreRegionSchema and GatewayTargetTypeSchema (catches accidental removal of enum values) * fix: set iamRoleFallback to true for lambda gateway targets (#1086) * fix: set iamRoleFallback to true for lambda gateway targets Keep TARGET_TYPE_AUTH_CONFIG in sync with @aws/agentcore-cdk — lambda targets need GATEWAY_IAM_ROLE just like lambdaFunctionArn targets. Related: https://github.com/aws/agentcore-cli/issues/1005 * chore: trigger CI re-run * style: fix prettier table alignment in docs/frameworks.md * fix: correct AB test execution role IAM policy and promote stability (#1120) * fix: add GetEvaluator permission to AB test execution role The AB test API validates evaluator ARNs server-side using the execution role. The auto-created role policy was missing bedrock-agentcore:GetEvaluator, causing a 400 "Access denied when validating evaluator" error on every deploy that included a target-based AB test with a custom evaluator. * fix: correct status assertion in target-based AB test e2e HTTP gateways are not surfaced as top-level resources in agentcore status — they are only used internally to build AB test invocation URLs. The test was asserting on resourceType 'http-gateway' which never appears; fix it to assert on the 'ab-test' resource instead. * fix: add invocationUrl to status resource type cast in e2e test The resource cast was missing invocationUrl, causing a TypeScript error when asserting the AB test's gateway invocation URL was present. * fix: improve promote error message and add local e2e run script - Include stdout in promote failure message so the JSON error is visible - Add scripts/run-e2e-local.sh to replicate the GitHub Actions e2e workflow locally * fix: retry promote stop on 409 UPDATING status When promote is called immediately after resume, the AB test may still be in UPDATING state and reject the STOPPED transition with a 409. Retry up to 6 times with a 10s delay to wait for the transition to complete. * fix: wait for AB test to leave UPDATING state before promoting Poll getABTest until executionStatus is no longer UPDATING before attempting to stop. The service rejects updates with 409 while a state transition is in progress (e.g. after resume). * fix: wait for RUNNING before stopping AB test in promote Poll getABTest until executionStatus === 'RUNNING' before issuing the STOPPED transition. The service 409s if the test is still UPDATING (e.g. transitioning from a prior resume). * fix: resolve evaluator ARNs transitively from AB test online eval configs Previously GetEvaluator was scoped to all project evaluators. Now it's scoped to only the evaluators referenced by the specific online eval configs this AB test uses, handling all three cases: - Custom evaluators: looked up from deployedResources.evaluators - Builtin.* evaluators: ARN constructed from the builtin ID - External ARN references: used as-is * fix: align AB test execution role policy with official docs Replace the hand-rolled per-resource policy with the canonical policy from https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/ab-testing-prereqs.html: - Trust policy: add SourceAccount + SourceArn conditions - Permissions: single AgentCoreResources statement scoped to arn:aws:bedrock-agentcore:*:${accountId}:* with ResourceAccount condition - Add missing actions: GetGatewayTarget, ListGatewayTargets, ListConfigurationBundleVersions - CloudWatch logs scoped to account via PrincipalAccount pattern - Remove per-evaluator/per-resource ARN tracking (no longer needed) * test: update IAM role unit tests to match new policy structure * fix: throw error if AB test never reaches RUNNING before promote * test: add unit tests for promote polling logic and extract to promote-utils Extract waitForRunningThenStop into promote-utils.ts so it can be unit tested without pulling in React/ink. Add 4 tests covering: immediate RUNNING, polling until RUNNING, timeout throws, and error message content. * fix: split DescribeLogGroups into wildcard resource statement * test: verify AB test reaches RUNNING status after deploy in both e2e suites * fix: remove console.log statements that leaked ARNs and fix bundle ARN/version format in config-bundle e2e test * test: remove second deploy and RUNNING check from config-bundle e2e (follow-up with real bundles) --------- Co-authored-by: Local E2E * chore: bump version to 0.13.1 (#1129) Co-authored-by: github-actions[bot] --------- Signed-off-by: dependabot[bot] Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Co-authored-by: Trirmadura J Ariyawansa Co-authored-by: Avi Alpert <131792194+avi-alpert@users.noreply.github.com> Co-authored-by: Gitika <53349492+notgitika@users.noreply.github.com> Co-authored-by: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Co-authored-by: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Imran Ismail Co-authored-by: Claude Opus 4.6 Co-authored-by: Jesse Turner Co-authored-by: Avi Alpert Co-authored-by: Aidan Daly Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Álvaro <159990212+alvarog2491@users.noreply.github.com> Co-authored-by: Yasuhiro Horiuchi Co-authored-by: kashinoki38 <21358299+kashinoki38@users.noreply.github.com> Co-authored-by: padmak30 Co-authored-by: padmak30 Co-authored-by: Minoru Onda(みのるん) <74597894+minorun365@users.noreply.github.com> Co-authored-by: minorun365 Co-authored-by: Local E2E --- .github/harness/Dockerfile | 33 + .github/harness/README.md | 39 + .github/harness/harness_review.py | 200 +++ .github/harness/prompts/review.md | 18 + .github/harness/prompts/system.md | 25 + .github/workflows/build-and-test.yml | 78 +- .github/workflows/e2e-tests-full.yml | 50 +- .github/workflows/e2e-tests.yml | 24 +- .github/workflows/pr-ai-review.yml | 159 +++ .github/workflows/pr-size.yml | 2 +- .../workflows/release-main-and-preview.yml | 496 ++++++++ .github/workflows/release.yml | 2 +- .../workflows/slack-open-prs-notification.yml | 2 +- .github/workflows/strands-command.yml | 8 +- .github/workflows/sync-preview.yml | 125 ++ CHANGELOG.md | 44 + README.md | 36 +- docs/TESTING.md | 41 +- docs/batch-evaluation.md | 143 +++ docs/config-bundles.md | 114 ++ docs/frameworks.md | 38 +- docs/policies/iam-policy-user.json | 167 ++- docs/recommendations.md | 158 +++ e2e-tests/README.md | 124 ++ e2e-tests/ab-test-config-bundle.test.ts | 209 +++ e2e-tests/ab-test-target-based.test.ts | 317 +++++ e2e-tests/archive-lifecycle.test.ts | 333 +++++ e2e-tests/byo-custom-jwt.test.ts | 2 +- e2e-tests/config-bundle-eval-rec.test.ts | 638 ++++++++++ e2e-tests/e2e-helper.ts | 43 +- e2e-tests/fixtures/import/common.py | 54 +- e2e-tests/fixtures/import/setup_evaluator.py | 5 +- e2e-tests/fixtures/import/setup_gateway.py | 87 ++ .../fixtures/import/setup_memory_full.py | 6 +- .../fixtures/import/setup_runtime_basic.py | 7 +- e2e-tests/import-gateway.test.ts | 196 +++ e2e-tests/import-resources.test.ts | 16 +- esbuild.config.mjs | 2 +- integ-tests/README.md | 113 +- .../add-remove-ab-test-target-based.test.ts | 461 +++++++ integ-tests/add-remove-ab-test.test.ts | 170 +++ integ-tests/add-remove-config-bundle.test.ts | 312 +++++ .../add-remove-online-eval-endpoint.test.ts | 199 +++ integ-tests/add-remove-resources.test.ts | 58 +- integ-tests/create-frameworks.test.ts | 1 - integ-tests/create-memory.test.ts | 1 - integ-tests/create-no-agent.test.ts | 2 +- integ-tests/create-protocols.test.ts | 1 - integ-tests/create-with-agent.test.ts | 2 +- integ-tests/dev-server.test.ts | 2 +- integ-tests/help.test.ts | 56 +- integ-tests/recommendation.test.ts | 290 +++++ package-lock.json | 1128 +++++++---------- package.json | 10 +- schemas/agentcore.schema.v1.json | 342 +++++ scripts/bundle.mjs | 7 +- scripts/run-e2e-local.sh | 112 ++ .../assets.snapshot.test.ts.snap | 142 ++- src/assets/__tests__/assets.snapshot.test.ts | 34 +- src/assets/cdk/test/cdk.test.ts | 1 + .../http/langchain_langgraph/base/main.py | 60 +- src/assets/python/http/strands/base/main.py | 79 +- src/cli/__tests__/global-config.test.ts | 43 +- .../aws/__tests__/agentcore-ab-tests.test.ts | 345 +++++ .../__tests__/agentcore-http-gateways.test.ts | 235 ++++ .../agentcore-recommendation.test.ts | 295 +++++ src/cli/aws/__tests__/agentcore.test.ts | 42 +- src/cli/aws/agentcore-ab-tests.ts | 360 ++++++ src/cli/aws/agentcore-batch-evaluation.ts | 411 ++++++ src/cli/aws/agentcore-config-bundles.ts | 368 ++++++ src/cli/aws/agentcore-control.ts | 362 ++++++ src/cli/aws/agentcore-http-gateways.ts | 519 ++++++++ src/cli/aws/agentcore-recommendation.ts | 371 ++++++ src/cli/aws/agentcore.ts | 53 +- src/cli/aws/index.ts | 18 + src/cli/cli.ts | 27 +- .../__tests__/outputs-extended.test.ts | 8 +- .../cloudformation/__tests__/outputs.test.ts | 114 ++ src/cli/cloudformation/outputs.ts | 28 +- src/cli/commands/abtest/command.ts | 199 +++ src/cli/commands/abtest/index.ts | 1 + src/cli/commands/add/types.ts | 1 + .../archive/__tests__/command.test.ts | 396 ++++++ src/cli/commands/archive/command.tsx | 93 ++ src/cli/commands/archive/index.ts | 1 + src/cli/commands/config-bundle/command.tsx | 347 +++++ src/cli/commands/config-bundle/index.ts | 1 + src/cli/commands/create/action.ts | 9 + src/cli/commands/create/command.tsx | 2 + src/cli/commands/create/types.ts | 1 + .../commands/deploy/__tests__/deploy.test.ts | 9 +- src/cli/commands/deploy/actions.ts | 183 ++- src/cli/commands/deploy/command.tsx | 10 +- src/cli/commands/deploy/types.ts | 1 + src/cli/commands/dev/__tests__/dev.test.ts | 7 - src/cli/commands/dev/browser-mode.ts | 43 + src/cli/commands/help/command.tsx | 19 +- .../__tests__/import-gateway-cfn.test.ts | 279 ++++ .../__tests__/import-gateway-flow.test.ts | 502 ++++++++ .../__tests__/import-gateway-spec.test.ts | 311 +++++ .../__tests__/import-gateway-targets.test.ts | 355 ++++++ .../import/__tests__/import-gateway.test.ts | 250 ++++ src/cli/commands/import/actions.ts | 22 +- src/cli/commands/import/command.ts | 2 + src/cli/commands/import/constants.ts | 1 + src/cli/commands/import/import-evaluator.ts | 16 +- src/cli/commands/import/import-gateway.ts | 690 ++++++++++ src/cli/commands/import/import-pipeline.ts | 4 +- src/cli/commands/import/import-utils.ts | 37 +- src/cli/commands/import/resource-import.ts | 38 +- src/cli/commands/import/template-utils.ts | 7 +- src/cli/commands/import/types.ts | 2 +- src/cli/commands/index.ts | 2 + src/cli/commands/invoke/action.ts | 17 + .../commands/logs/__tests__/action.test.ts | 12 + .../commands/pause/__tests__/promote.test.ts | 59 + src/cli/commands/pause/command.tsx | 243 ++++ src/cli/commands/pause/index.ts | 2 +- src/cli/commands/pause/promote-utils.ts | 28 + src/cli/commands/recommendations/command.tsx | 63 + src/cli/commands/recommendations/index.ts | 1 + src/cli/commands/remove/command.tsx | 3 + src/cli/commands/remove/types.ts | 4 +- src/cli/commands/run/command.tsx | 407 +++++- .../shared/__tests__/region-utils.test.ts | 130 ++ src/cli/commands/shared/region-utils.ts | 13 + src/cli/commands/status/action.ts | 60 + src/cli/commands/status/command.tsx | 36 +- src/cli/commands/stop/command.tsx | 45 + src/cli/commands/stop/index.ts | 1 + .../telemetry/__tests__/telemetry.test.ts | 2 +- src/cli/commands/telemetry/actions.ts | 2 +- src/cli/commands/update/command.tsx | 51 +- .../__tests__/checks-extended.test.ts | 30 + src/cli/logging/remove-logger.ts | 4 +- .../ab-test/__tests__/promote.test.ts | 270 ++++ src/cli/operations/ab-test/promote.ts | 124 ++ .../agent/config-bundle-defaults.ts | 30 + .../agent/generate/schema-mapper.ts | 1 + .../agent/generate/write-agent-to-project.ts | 3 + .../archive/__tests__/archive-storage.test.ts | 130 ++ src/cli/operations/archive/archive-storage.ts | 43 + src/cli/operations/archive/index.ts | 1 + .../__tests__/bundle-name-variants.test.ts | 22 + .../__tests__/resolve-bundle.test.ts | 103 ++ .../config-bundle/bundle-name-variants.ts | 11 + .../operations/config-bundle/diff-versions.ts | 63 + .../config-bundle/resolve-bundle.ts | 91 ++ .../__tests__/post-deploy-ab-tests.test.ts | 596 +++++++++ .../post-deploy-config-bundles.test.ts | 651 ++++++++++ .../post-deploy-http-gateways.test.ts | 468 +++++++ .../post-deploy-observability.test.ts | 14 +- .../post-deploy-online-evals.test.ts | 179 +++ .../deploy/__tests__/preflight.test.ts | 55 + src/cli/operations/deploy/index.ts | 24 + .../operations/deploy/post-deploy-ab-tests.ts | 723 +++++++++++ .../deploy/post-deploy-config-bundles.ts | 348 +++++ .../deploy/post-deploy-http-gateways.ts | 652 ++++++++++ .../deploy/post-deploy-observability.ts | 4 +- .../deploy/post-deploy-online-evals.ts | 80 ++ src/cli/operations/deploy/preflight.ts | 35 + src/cli/operations/deploy/teardown.ts | 80 ++ .../operations/dev/__tests__/config.test.ts | 63 + .../__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 + .../dev/web-ui/handlers/invocations.ts | 1 + src/cli/operations/dev/web-ui/index.ts | 7 + src/cli/operations/dev/web-ui/web-server.ts | 33 + src/cli/operations/eval/batch-eval-storage.ts | 75 ++ .../operations/eval/run-batch-evaluation.ts | 347 +++++ src/cli/operations/eval/run-eval.ts | 2 +- .../operations/fetch-access/list-gateways.ts | 12 + .../identity/__tests__/credential-ops.test.ts | 17 - .../__tests__/apply-to-bundle.test.ts | 199 +++ .../__tests__/fetch-session-spans.test.ts | 224 ++++ .../__tests__/recommendation-storage.test.ts | 134 ++ .../__tests__/run-recommendation.test.ts | 700 ++++++++++ .../recommendation/apply-to-bundle.ts | 140 ++ .../operations/recommendation/constants.ts | 11 + .../recommendation/fetch-session-spans.ts | 158 +++ src/cli/operations/recommendation/index.ts | 18 + .../recommendation/recommendation-storage.ts | 84 ++ .../recommendation/run-recommendation.ts | 610 +++++++++ src/cli/operations/recommendation/types.ts | 72 ++ .../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 ++ src/cli/primitives/ABTestPrimitive.ts | 728 +++++++++++ src/cli/primitives/AgentPrimitive.tsx | 199 +-- src/cli/primitives/ConfigBundlePrimitive.ts | 237 ++++ src/cli/primitives/CredentialPrimitive.tsx | 66 +- src/cli/primitives/EvaluatorPrimitive.ts | 49 +- src/cli/primitives/GatewayPrimitive.ts | 51 +- src/cli/primitives/GatewayTargetPrimitive.ts | 70 +- src/cli/primitives/MemoryPrimitive.tsx | 55 +- .../primitives/OnlineEvalConfigPrimitive.ts | 84 +- src/cli/primitives/PolicyEnginePrimitive.ts | 55 +- src/cli/primitives/PolicyPrimitive.ts | 75 +- .../primitives/RuntimeEndpointPrimitive.ts | 30 +- .../__tests__/ABTestPrimitive.test.ts | 278 ++++ .../__tests__/GatewayPrimitive.test.ts | 3 + .../primitives/__tests__/auth-utils.test.ts | 3 + src/cli/primitives/index.ts | 3 + src/cli/primitives/registry.ts | 6 + src/cli/project.ts | 3 + src/cli/telemetry/__tests__/client.test.ts | 37 +- .../__tests__/filesystem-sink.test.ts | 95 ++ src/cli/telemetry/cli-command-run.ts | 71 ++ src/cli/telemetry/client-accessor.ts | 49 + src/cli/telemetry/client.ts | 17 +- src/cli/telemetry/config.ts | 7 +- src/cli/telemetry/index.ts | 4 +- .../schemas/__tests__/command-run.test.ts | 54 +- src/cli/telemetry/schemas/command-run.ts | 2 + src/cli/telemetry/schemas/common-shapes.ts | 44 + src/cli/telemetry/sinks/filesystem-sink.ts | 48 + src/cli/templates/types.ts | 2 + src/cli/tui/App.tsx | 60 +- src/cli/tui/components/DeployStatus.tsx | 40 +- src/cli/tui/components/MultiSelectList.tsx | 28 +- src/cli/tui/components/PathInput.tsx | 24 +- src/cli/tui/components/ResourceGraph.tsx | 53 +- src/cli/tui/components/SelectList.tsx | 26 +- src/cli/tui/components/WizardSelect.tsx | 22 +- .../__tests__/ConfirmReview.test.tsx | 89 -- .../tui/components/__tests__/Cursor.test.tsx | 40 - .../__tests__/DeployStatus.test.tsx | 49 + .../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 - src/cli/tui/constants.ts | 2 + src/cli/tui/copy.ts | 60 +- .../__tests__/usePanelNavigation.test.tsx | 347 +++++ src/cli/tui/hooks/useCreateABTest.ts | 93 ++ src/cli/tui/hooks/useCreateConfigBundle.ts | 59 + src/cli/tui/hooks/useCreateEvaluator.ts | 20 +- src/cli/tui/hooks/useCreateMcp.ts | 45 +- src/cli/tui/hooks/useCreateMemory.ts | 27 +- src/cli/tui/hooks/useCreateOnlineEval.ts | 27 +- src/cli/tui/hooks/usePanelNavigation.ts | 196 +++ src/cli/tui/hooks/useRemove.ts | 40 + .../screens/ab-test/ABTestDetailScreen.tsx | 623 +++++++++ .../screens/ab-test/ABTestPickerScreen.tsx | 90 ++ src/cli/tui/screens/ab-test/AddABTestFlow.tsx | 281 ++++ .../tui/screens/ab-test/AddABTestScreen.tsx | 914 +++++++++++++ .../screens/ab-test/RemoveABTestScreen.tsx | 26 + .../ab-test/TargetBasedABTestScreen.tsx | 712 +++++++++++ .../tui/screens/ab-test/VariantConfigForm.tsx | 268 ++++ .../__tests__/useAddABTestWizard.test.tsx | 286 +++++ .../__tests__/useTargetBasedWizard.test.tsx | 319 +++++ src/cli/tui/screens/ab-test/index.ts | 4 + src/cli/tui/screens/ab-test/types.ts | 89 ++ .../tui/screens/ab-test/useAddABTestWizard.ts | 324 +++++ .../screens/ab-test/useTargetBasedWizard.ts | 188 +++ src/cli/tui/screens/add/AddFlow.tsx | 40 + src/cli/tui/screens/add/AddScreen.tsx | 2 + src/cli/tui/screens/agent/AddAgentScreen.tsx | 9 + src/cli/tui/screens/agent/types.ts | 2 + src/cli/tui/screens/agent/useAddAgent.ts | 103 +- .../config-bundle-hub/ConfigBundleFlow.tsx | 60 + .../ConfigBundleHubScreen.tsx | 129 ++ .../screens/config-bundle-hub/DiffScreen.tsx | 149 +++ .../VersionHistoryScreen.tsx | 245 ++++ .../tui/screens/config-bundle-hub/index.ts | 4 + .../config-bundle-hub/useConfigBundleHub.ts | 220 ++++ .../config-bundle/AddConfigBundleFlow.tsx | 177 +++ .../config-bundle/AddConfigBundleScreen.tsx | 275 ++++ src/cli/tui/screens/config-bundle/index.ts | 1 + src/cli/tui/screens/config-bundle/types.ts | 57 + .../config-bundle/useAddConfigBundleWizard.ts | 113 ++ src/cli/tui/screens/create/useCreateFlow.ts | 7 + src/cli/tui/screens/deploy/DeployScreen.tsx | 24 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 231 +++- src/cli/tui/screens/eval/EvalHubScreen.tsx | 14 +- .../tui/screens/generate/GenerateWizardUI.tsx | 6 + src/cli/tui/screens/generate/types.ts | 16 +- .../tui/screens/generate/useGenerateWizard.ts | 4 + src/cli/tui/screens/home/HelpScreen.tsx | 4 +- .../tui/screens/identity/useCreateIdentity.ts | 9 +- src/cli/tui/screens/import/ArnInputScreen.tsx | 2 + .../screens/import/ImportProgressScreen.tsx | 4 +- .../tui/screens/import/ImportSelectScreen.tsx | 7 +- src/cli/tui/screens/invoke/useInvokeFlow.ts | 18 + .../screens/online-eval/AddOnlineEvalFlow.tsx | 22 +- .../online-eval/AddOnlineEvalScreen.tsx | 65 +- src/cli/tui/screens/online-eval/types.ts | 17 +- .../online-eval/useAddOnlineEvalWizard.ts | 43 +- src/cli/tui/screens/policy/AddPolicyFlow.tsx | 33 +- .../recommendation/RecommendationFlow.tsx | 552 ++++++++ .../RecommendationHistoryScreen.tsx | 250 ++++ .../recommendation/RecommendationScreen.tsx | 599 +++++++++ .../RecommendationsHubScreen.tsx | 43 + src/cli/tui/screens/recommendation/index.ts | 3 + src/cli/tui/screens/recommendation/types.ts | 86 ++ .../recommendation/useRecommendationWizard.ts | 232 ++++ .../remove/RemoveConfigBundleScreen.tsx | 26 + src/cli/tui/screens/remove/RemoveFlow.tsx | 213 +++- src/cli/tui/screens/remove/RemoveScreen.tsx | 22 + .../remove/__tests__/RemoveScreen.test.tsx | 57 + .../run-eval/BatchEvalHistoryScreen.tsx | 307 +++++ .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 968 ++++++++++++++ src/cli/tui/screens/run-eval/RunScreen.tsx | 20 +- src/cli/tui/screens/run-eval/index.ts | 2 + .../AddRuntimeEndpointFlow.tsx | 27 +- src/cli/tui/utils/commands.ts | 4 +- src/lib/packaging/build-args.ts | 4 +- src/lib/schemas/io/cli-config.ts | 36 - src/lib/schemas/io/config-io.ts | 7 +- src/{cli => lib/schemas/io}/global-config.ts | 29 +- src/lib/schemas/io/index.ts | 1 - src/schema/__tests__/constants.test.ts | 59 +- src/schema/llm-compacted/agentcore.ts | 309 ++++- .../schemas/__tests__/agent-env.test.ts | 136 +- .../__tests__/agentcore-project.test.ts | 76 +- .../schemas/__tests__/aws-targets.test.ts | 54 +- .../schemas/__tests__/deployed-state.test.ts | 52 +- src/schema/schemas/__tests__/mcp-defs.test.ts | 30 +- src/schema/schemas/__tests__/mcp.test.ts | 40 +- src/schema/schemas/agentcore-project.ts | 104 ++ src/schema/schemas/deployed-state.ts | 51 + src/schema/schemas/mcp.ts | 12 +- .../primitives/__tests__/ab-test.test.ts | 228 ++++ .../primitives/__tests__/evaluator.test.ts | 11 +- .../primitives/__tests__/http-gateway.test.ts | 82 ++ .../primitives/__tests__/memory.test.ts | 43 +- src/schema/schemas/primitives/ab-test.ts | 147 +++ .../schemas/primitives/config-bundle.ts | 49 + src/schema/schemas/primitives/http-gateway.ts | 41 + src/schema/schemas/primitives/index.ts | 24 + .../schemas/primitives/online-eval-config.ts | 2 + src/test-utils/cli-runner.ts | 12 +- src/test-utils/index.ts | 20 + src/test-utils/project-factory.ts | 2 +- src/test-utils/telemetry-helper.ts | 56 + 344 files changed, 38300 insertions(+), 2454 deletions(-) create mode 100644 .github/harness/Dockerfile create mode 100644 .github/harness/README.md create mode 100644 .github/harness/harness_review.py create mode 100644 .github/harness/prompts/review.md create mode 100644 .github/harness/prompts/system.md create mode 100644 .github/workflows/pr-ai-review.yml create mode 100644 .github/workflows/release-main-and-preview.yml create mode 100644 .github/workflows/sync-preview.yml create mode 100644 docs/batch-evaluation.md create mode 100644 docs/config-bundles.md create mode 100644 docs/recommendations.md create mode 100644 e2e-tests/README.md create mode 100644 e2e-tests/ab-test-config-bundle.test.ts create mode 100644 e2e-tests/ab-test-target-based.test.ts create mode 100644 e2e-tests/archive-lifecycle.test.ts create mode 100644 e2e-tests/config-bundle-eval-rec.test.ts create mode 100644 e2e-tests/fixtures/import/setup_gateway.py create mode 100644 e2e-tests/import-gateway.test.ts create mode 100644 integ-tests/add-remove-ab-test-target-based.test.ts create mode 100644 integ-tests/add-remove-ab-test.test.ts create mode 100644 integ-tests/add-remove-config-bundle.test.ts create mode 100644 integ-tests/add-remove-online-eval-endpoint.test.ts create mode 100644 integ-tests/recommendation.test.ts create mode 100755 scripts/run-e2e-local.sh create mode 100644 src/cli/aws/__tests__/agentcore-ab-tests.test.ts create mode 100644 src/cli/aws/__tests__/agentcore-http-gateways.test.ts create mode 100644 src/cli/aws/__tests__/agentcore-recommendation.test.ts create mode 100644 src/cli/aws/agentcore-ab-tests.ts create mode 100644 src/cli/aws/agentcore-batch-evaluation.ts create mode 100644 src/cli/aws/agentcore-config-bundles.ts create mode 100644 src/cli/aws/agentcore-http-gateways.ts create mode 100644 src/cli/aws/agentcore-recommendation.ts create mode 100644 src/cli/commands/abtest/command.ts create mode 100644 src/cli/commands/abtest/index.ts create mode 100644 src/cli/commands/archive/__tests__/command.test.ts create mode 100644 src/cli/commands/archive/command.tsx create mode 100644 src/cli/commands/archive/index.ts create mode 100644 src/cli/commands/config-bundle/command.tsx create mode 100644 src/cli/commands/config-bundle/index.ts create mode 100644 src/cli/commands/import/__tests__/import-gateway-cfn.test.ts create mode 100644 src/cli/commands/import/__tests__/import-gateway-flow.test.ts create mode 100644 src/cli/commands/import/__tests__/import-gateway-spec.test.ts create mode 100644 src/cli/commands/import/__tests__/import-gateway-targets.test.ts create mode 100644 src/cli/commands/import/__tests__/import-gateway.test.ts create mode 100644 src/cli/commands/import/import-gateway.ts create mode 100644 src/cli/commands/pause/__tests__/promote.test.ts create mode 100644 src/cli/commands/pause/promote-utils.ts create mode 100644 src/cli/commands/recommendations/command.tsx create mode 100644 src/cli/commands/recommendations/index.ts create mode 100644 src/cli/commands/shared/__tests__/region-utils.test.ts create mode 100644 src/cli/commands/shared/region-utils.ts create mode 100644 src/cli/commands/stop/command.tsx create mode 100644 src/cli/commands/stop/index.ts create mode 100644 src/cli/operations/ab-test/__tests__/promote.test.ts create mode 100644 src/cli/operations/ab-test/promote.ts create mode 100644 src/cli/operations/agent/config-bundle-defaults.ts create mode 100644 src/cli/operations/archive/__tests__/archive-storage.test.ts create mode 100644 src/cli/operations/archive/archive-storage.ts create mode 100644 src/cli/operations/archive/index.ts create mode 100644 src/cli/operations/config-bundle/__tests__/bundle-name-variants.test.ts create mode 100644 src/cli/operations/config-bundle/__tests__/resolve-bundle.test.ts create mode 100644 src/cli/operations/config-bundle/bundle-name-variants.ts create mode 100644 src/cli/operations/config-bundle/diff-versions.ts create mode 100644 src/cli/operations/config-bundle/resolve-bundle.ts create mode 100644 src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts create mode 100644 src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts create mode 100644 src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts create mode 100644 src/cli/operations/deploy/__tests__/post-deploy-online-evals.test.ts create mode 100644 src/cli/operations/deploy/post-deploy-ab-tests.ts create mode 100644 src/cli/operations/deploy/post-deploy-config-bundles.ts create mode 100644 src/cli/operations/deploy/post-deploy-http-gateways.ts create mode 100644 src/cli/operations/deploy/post-deploy-online-evals.ts 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/eval/batch-eval-storage.ts create mode 100644 src/cli/operations/eval/run-batch-evaluation.ts create mode 100644 src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts create mode 100644 src/cli/operations/recommendation/__tests__/fetch-session-spans.test.ts create mode 100644 src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts create mode 100644 src/cli/operations/recommendation/__tests__/run-recommendation.test.ts create mode 100644 src/cli/operations/recommendation/apply-to-bundle.ts create mode 100644 src/cli/operations/recommendation/constants.ts create mode 100644 src/cli/operations/recommendation/fetch-session-spans.ts create mode 100644 src/cli/operations/recommendation/index.ts create mode 100644 src/cli/operations/recommendation/recommendation-storage.ts create mode 100644 src/cli/operations/recommendation/run-recommendation.ts create mode 100644 src/cli/operations/recommendation/types.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 create mode 100644 src/cli/primitives/ABTestPrimitive.ts create mode 100644 src/cli/primitives/ConfigBundlePrimitive.ts create mode 100644 src/cli/primitives/__tests__/ABTestPrimitive.test.ts create mode 100644 src/cli/telemetry/__tests__/filesystem-sink.test.ts create mode 100644 src/cli/telemetry/cli-command-run.ts create mode 100644 src/cli/telemetry/client-accessor.ts create mode 100644 src/cli/telemetry/sinks/filesystem-sink.ts 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 create mode 100644 src/cli/tui/hooks/__tests__/usePanelNavigation.test.tsx create mode 100644 src/cli/tui/hooks/useCreateABTest.ts create mode 100644 src/cli/tui/hooks/useCreateConfigBundle.ts create mode 100644 src/cli/tui/hooks/usePanelNavigation.ts create mode 100644 src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx create mode 100644 src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx create mode 100644 src/cli/tui/screens/ab-test/AddABTestFlow.tsx create mode 100644 src/cli/tui/screens/ab-test/AddABTestScreen.tsx create mode 100644 src/cli/tui/screens/ab-test/RemoveABTestScreen.tsx create mode 100644 src/cli/tui/screens/ab-test/TargetBasedABTestScreen.tsx create mode 100644 src/cli/tui/screens/ab-test/VariantConfigForm.tsx create mode 100644 src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx create mode 100644 src/cli/tui/screens/ab-test/__tests__/useTargetBasedWizard.test.tsx create mode 100644 src/cli/tui/screens/ab-test/index.ts create mode 100644 src/cli/tui/screens/ab-test/types.ts create mode 100644 src/cli/tui/screens/ab-test/useAddABTestWizard.ts create mode 100644 src/cli/tui/screens/ab-test/useTargetBasedWizard.ts create mode 100644 src/cli/tui/screens/config-bundle-hub/ConfigBundleFlow.tsx create mode 100644 src/cli/tui/screens/config-bundle-hub/ConfigBundleHubScreen.tsx create mode 100644 src/cli/tui/screens/config-bundle-hub/DiffScreen.tsx create mode 100644 src/cli/tui/screens/config-bundle-hub/VersionHistoryScreen.tsx create mode 100644 src/cli/tui/screens/config-bundle-hub/index.ts create mode 100644 src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts create mode 100644 src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx create mode 100644 src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx create mode 100644 src/cli/tui/screens/config-bundle/index.ts create mode 100644 src/cli/tui/screens/config-bundle/types.ts create mode 100644 src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts create mode 100644 src/cli/tui/screens/recommendation/RecommendationFlow.tsx create mode 100644 src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx create mode 100644 src/cli/tui/screens/recommendation/RecommendationScreen.tsx create mode 100644 src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx create mode 100644 src/cli/tui/screens/recommendation/index.ts create mode 100644 src/cli/tui/screens/recommendation/types.ts create mode 100644 src/cli/tui/screens/recommendation/useRecommendationWizard.ts create mode 100644 src/cli/tui/screens/remove/RemoveConfigBundleScreen.tsx create mode 100644 src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx create mode 100644 src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx delete mode 100644 src/lib/schemas/io/cli-config.ts rename src/{cli => lib/schemas/io}/global-config.ts (73%) create mode 100644 src/schema/schemas/primitives/__tests__/ab-test.test.ts create mode 100644 src/schema/schemas/primitives/__tests__/http-gateway.test.ts create mode 100644 src/schema/schemas/primitives/ab-test.ts create mode 100644 src/schema/schemas/primitives/config-bundle.ts create mode 100644 src/schema/schemas/primitives/http-gateway.ts create mode 100644 src/test-utils/telemetry-helper.ts diff --git a/.github/harness/Dockerfile b/.github/harness/Dockerfile new file mode 100644 index 000000000..3deec1a46 --- /dev/null +++ b/.github/harness/Dockerfile @@ -0,0 +1,33 @@ +FROM public.ecr.aws/docker/library/python:3.12-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + git \ + curl \ + jq \ + && rm -rf /var/lib/apt/lists/* + +# Install GitHub CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y gh \ + && rm -rf /var/lib/apt/lists/* + +# Tokens are baked into the image at build time. This image must be treated as a +# secret and stored only in a registry with equivalent access controls. +ARG CLONE_TOKEN +ARG GITHUB_TOKEN + +# Configure git to use clone token for HTTPS clones +RUN git config --global url."https://${CLONE_TOKEN}@github.com/".insteadOf "https://github.com/" + +# Persist gh CLI auth so GITHUB_TOKEN doesn't need to be in the environment +RUN mkdir -p /root/.config/gh \ + && echo "github.com:" > /root/.config/gh/hosts.yml \ + && echo " oauth_token: ${GITHUB_TOKEN}" >> /root/.config/gh/hosts.yml \ + && echo " user: agentcore-cli-automation" >> /root/.config/gh/hosts.yml \ + && echo " git_protocol: https" >> /root/.config/gh/hosts.yml + +WORKDIR /opt/workspace diff --git a/.github/harness/README.md b/.github/harness/README.md new file mode 100644 index 000000000..d9ba15c61 --- /dev/null +++ b/.github/harness/README.md @@ -0,0 +1,39 @@ +# Harness Resources + +Container and scripts for AI-powered automation via +[AgentCore Harness](https://docs.aws.amazon.com/bedrock/latest/userguide/agentcore.html). + +## Structure + +``` +harness/ +├── Dockerfile # Container image for the harness runtime +├── harness_review.py # Invokes the harness to review PRs (SigV4 + event stream) +└── prompts/ + ├── system.md # System prompt (workspace context) + └── review.md # PR review task prompt +``` + +## Current: PR Reviewer + +Reviews pull requests on open/reopen via `.github/workflows/pr-ai-review.yml`. + +### Dual-token setup + +The Dockerfile takes two build args: + +- **`CLONE_TOKEN`** — baked into git config for cloning private repos +- **`GITHUB_TOKEN`** — baked into `gh` CLI auth for posting PR comments + +### Building the container + +```bash +finch build \ + --build-arg CLONE_TOKEN= \ + --build-arg GITHUB_TOKEN= \ + -t pr-reviewer .github/harness/ +``` + +## Future: Tester + +This directory will also house a harness-based test runner. diff --git a/.github/harness/harness_review.py b/.github/harness/harness_review.py new file mode 100644 index 000000000..2ee174266 --- /dev/null +++ b/.github/harness/harness_review.py @@ -0,0 +1,200 @@ +"""Invoke Bedrock AgentCore Harness to review a GitHub PR. + +Reads PR_URL from the environment. Streams harness output to stdout. +Uses the boto3 bedrock-agentcore client's invoke_harness API. +""" + +import json +import os +import sys +import time +import uuid + +import boto3 + +# ANSI color codes +CYAN = "\033[36m" +YELLOW = "\033[33m" +GREEN = "\033[32m" +RED = "\033[31m" +DIM = "\033[2m" +RESET = "\033[0m" + +SCRIPTS_DIR = os.path.dirname(__file__) + + +def read_prompt(filename): + """Read a prompt template from the prompts directory.""" + path = os.path.join(SCRIPTS_DIR, "prompts", filename) + with open(path) as f: + return f.read() + + +def invoke_harness_streaming(harness_arn, session_id, system_prompt, messages, model_id, region): + """Call invoke_harness via boto3 and return the event stream.""" + client = boto3.client("bedrock-agentcore", region_name=region) + response = client.invoke_harness( + harnessArn=harness_arn, + runtimeSessionId=session_id, + systemPrompt=[{"text": system_prompt}], + messages=messages, + model={"bedrockModelConfig": {"modelId": model_id}}, + ) + return response["stream"] + + +def parse_events(event_stream): + """Yield (event_type, payload) tuples from the boto3 event stream.""" + for event in event_stream: + if "contentBlockStart" in event: + yield "contentBlockStart", event["contentBlockStart"] + elif "contentBlockDelta" in event: + yield "contentBlockDelta", event["contentBlockDelta"] + elif "contentBlockStop" in event: + yield "contentBlockStop", event["contentBlockStop"] + elif "messageStop" in event: + yield "messageStop", event["messageStop"] + elif "internalServerException" in event: + yield "internalServerException", event["internalServerException"] + elif "runtimeClientError" in event: + yield "runtimeClientError", event["runtimeClientError"] + + +def print_stream(event_stream): + """Display harness events with GitHub Actions log groups. + + The harness streams events as the agent works: + contentBlockStart — a new block begins (text or tool call) + contentBlockDelta — incremental chunks of text or tool input JSON + contentBlockStop — block complete, we now have full tool input to display + messageStop — agent finished + internalServerException — server error + + Tool calls are wrapped in ::group::/::endgroup:: for collapsible sections + in the GitHub Actions log UI. Agent reasoning text is printed inline in dim. + """ + start_time = time.time() + iteration = 0 + tool_name = None + tool_input = "" + tool_start = 0.0 + in_group = False + text_buffer = "" + + def close_group(): + nonlocal in_group + if in_group: + print("::endgroup::", flush=True) + in_group = False + + def flush_text(): + nonlocal text_buffer + if text_buffer: + for line in text_buffer.splitlines(): + print(f"{DIM}{line}{RESET}", flush=True) + text_buffer = "" + + for event_type, payload in parse_events(event_stream): + + if event_type == "contentBlockStart": + start = payload.get("start", {}) + if "toolUse" in start: + tool_name = start["toolUse"].get("name", "unknown") + tool_input = "" + tool_start = time.time() + iteration += 1 + + elif event_type == "contentBlockDelta": + delta = payload.get("delta", {}) + if "text" in delta: + close_group() + text_buffer += delta["text"] + if "toolUse" in delta: + tool_input += delta["toolUse"].get("input", "") + + elif event_type == "contentBlockStop": + flush_text() + if tool_name: + elapsed = time.time() - tool_start + try: + parsed = json.loads(tool_input) + except (json.JSONDecodeError, TypeError): + parsed = tool_input + + close_group() + + cmd = parsed.get("command") if isinstance(parsed, dict) else None + header = f"{CYAN}[{iteration}]{RESET} {YELLOW}{tool_name}{RESET} {DIM}({elapsed:.1f}s){RESET}" + if cmd: + header += f": $ {cmd}" + + print(f"::group::{header}", flush=True) + in_group = True + + if isinstance(parsed, dict): + for k, v in parsed.items(): + if k != "command": + print(f" {DIM}{k}:{RESET} {str(v)[:300]}", flush=True) + + tool_name = None + tool_input = "" + + elif event_type == "messageStop": + flush_text() + close_group() + if payload.get("stopReason") == "end_turn": + total = time.time() - start_time + print(f"\n\n{GREEN}{'=' * 50}", flush=True) + print(f" Done ({int(total // 60)}m {int(total % 60)}s)", flush=True) + print(f"{'=' * 50}{RESET}", flush=True) + + elif event_type == "internalServerException": + close_group() + print(f"\n{RED}ERROR: {payload}{RESET}", file=sys.stderr) + sys.exit(1) + + elif event_type == "runtimeClientError": + close_group() + print(f"\n{RED}ERROR: {payload.get('message', payload)}{RESET}", file=sys.stderr) + sys.exit(1) + + close_group() + total = time.time() - start_time + print(f"\n{GREEN}Review complete.{RESET} {DIM}({iteration} tool calls, {int(total)}s total){RESET}") + + +# --- Main --- + +# All config comes from environment variables (set via GitHub secrets/workflow) +MODEL_ID = os.environ.get("HARNESS_MODEL_ID", "us.anthropic.claude-opus-4-7") +HARNESS_ARN = os.environ.get("HARNESS_ARN", "") +PR_URL = os.environ.get("PR_URL", "") + +for name, val in [("HARNESS_ARN", HARNESS_ARN), ("PR_URL", PR_URL)]: + if not val: + print(f"{RED}ERROR: {name} environment variable is required{RESET}", file=sys.stderr) + sys.exit(1) + +# Extract region from the ARN (arn:aws:bedrock-agentcore:{region}:{account}:harness/{id}) +REGION = HARNESS_ARN.split(":")[3] +SESSION_ID = str(uuid.uuid4()).upper() + +print(f"{CYAN}Session:{RESET} {SESSION_ID}") +print(f"{CYAN}PR:{RESET} {PR_URL}") +print(f"{CYAN}Harness:{RESET} {HARNESS_ARN}") +print() + +SYSTEM_PROMPT = read_prompt("system.md") +REVIEW_PROMPT = read_prompt("review.md").format(pr_url=PR_URL) + +messages = [{"role": "user", "content": [{"text": REVIEW_PROMPT}]}] + +try: + event_stream = invoke_harness_streaming( + HARNESS_ARN, SESSION_ID, SYSTEM_PROMPT, messages, MODEL_ID, REGION + ) +except Exception as e: + print(f"{RED}ERROR: Failed to invoke harness: {e}{RESET}", file=sys.stderr) + sys.exit(1) + +print_stream(event_stream) diff --git a/.github/harness/prompts/review.md b/.github/harness/prompts/review.md new file mode 100644 index 000000000..d34c67b95 --- /dev/null +++ b/.github/harness/prompts/review.md @@ -0,0 +1,18 @@ +Review this GitHub PR: {pr_url} + +You have tools to fetch the PR diff, read files, search the web, and post comments on the PR. + +You have these repos cloned locally for context: + +- /opt/workspace/agentcore-cli — aws/agentcore-cli +- /opt/workspace/agentcore-l3-cdk-constructs — aws/agentcore-l3-cdk-constructs + +Before reviewing, read all existing comments on the PR to understand what has already been discussed. Do not repeat or +re-post issues that have already been raised in existing comments. + +Review the PR. If there are any serious issues that require code changes before merging, post a comment on the PR for +each issue explaining the problem. If there are multiple ways to fix an issue, list the options so the author can +choose. Skip style nits and minor suggestions — only flag things that actually need to change. + +If all serious issues have already been raised in existing comments, or if you found no new issues, post a single +comment on the PR saying it looks good to merge (or that all issues have already been flagged). diff --git a/.github/harness/prompts/system.md b/.github/harness/prompts/system.md new file mode 100644 index 000000000..52a3d2260 --- /dev/null +++ b/.github/harness/prompts/system.md @@ -0,0 +1,25 @@ +# AgentCore CLI Development Workspace + +This workspace contains two repos for developing and testing the AgentCore CLI. + +## Repositories + +### agentcore-cli/ (`aws/agentcore-cli`) + +The terminal experience for creating, developing, and deploying AI agents to AgentCore. Node.js/TypeScript CLI built +with Ink (React-based TUI). + +### agentcore-l3-cdk-constructs/ (`aws/agentcore-l3-cdk-constructs`) + +AWS CDK L3 constructs for declaring and deploying AgentCore infrastructure. Used by agentcore-cli to vend CDK projects +when users run `agentcore create`. + +## How they relate + +`agentcore-cli` is the main product. It vends CDK projects using constructs from `agentcore-l3-cdk-constructs`. + +## Testing with a bundled distribution + +Run `npm run bundle` in `agentcore-cli/` to create a tar distribution that includes the packaged +`agentcore-l3-cdk-constructs`. You can then install it globally with `npm install -g ` to test the CLI +end-to-end. diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 88b1a857a..80b17f987 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -22,7 +22,8 @@ jobs: strategy: fail-fast: false matrix: - node-version: [20.x, 22.x, 24.x] + node-version: + ${{ github.event_name == 'pull_request' && fromJSON('["20.x"]') || fromJSON('["20.x", "22.x", "24.x"]') }} steps: - uses: actions/checkout@v6 @@ -39,14 +40,7 @@ jobs: uses: astral-sh/setup-uv@v7 - run: npm ci - run: npm run build --if-present - - run: npm run test:unit - run: npm run test:integ - - name: Upload coverage artifact - if: matrix.node-version == '20.x' - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: coverage/ - name: Pack tarball if: matrix.node-version == '20.x' run: npm pack @@ -66,8 +60,72 @@ jobs: name: tarball path: '*.tgz' + unit-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + node-version: + ${{ github.event_name == 'pull_request' && fromJSON('["20.x"]') || fromJSON('["20.x", "22.x", "24.x"]') }} + shard: [1/3, 2/3, 3/3] + + steps: + - uses: actions/checkout@v6 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + - name: Configure git for tests + run: | + git config --global user.email "bedrock-agentcore-npm+ci@amazon.com" + git config --global user.name "CI" + - name: Install uv + uses: astral-sh/setup-uv@v7 + - run: npm ci + - run: npm run build --if-present + - name: Run unit tests (shard ${{ matrix.shard }}) + run: npx vitest run --project unit --shard=${{ matrix.shard }} --reporter=blob --reporter=verbose --coverage + - name: Upload blob report + if: always() + uses: actions/upload-artifact@v7 + with: + name: blob-${{ matrix.node-version }}-${{ strategy.job-index }} + path: .vitest-reports + retention-days: 1 + if-no-files-found: error + include-hidden-files: true + + merge-reports: + needs: unit-test + runs-on: ubuntu-latest + if: always() + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 20.x + cache: 'npm' + - run: npm ci + - name: Download blob reports (Node 20) + uses: actions/download-artifact@v8 + with: + pattern: blob-20.x-* + merge-multiple: true + path: .vitest-reports/ + - name: Merge reports + run: npx vitest --mergeReports=.vitest-reports/ --project unit --coverage + - name: Upload coverage artifact + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: coverage/ + coverage: - needs: build + needs: merge-reports runs-on: ubuntu-latest if: github.event_name == 'pull_request' permissions: @@ -86,6 +144,6 @@ jobs: with: json-summary-path: coverage/coverage-summary.json json-final-path: coverage/coverage-final.json - vite-config-path: vitest.unit.config.ts + vite-config-path: vitest.config.ts file-coverage-mode: none coverage-thresholds: '{ "lines": 50, "branches": 50, "functions": 50, "statements": 50 }' diff --git a/.github/workflows/e2e-tests-full.yml b/.github/workflows/e2e-tests-full.yml index 8c95c1015..6d68f1c3a 100644 --- a/.github/workflows/e2e-tests-full.yml +++ b/.github/workflows/e2e-tests-full.yml @@ -27,6 +27,7 @@ jobs: fail-fast: false matrix: cdk-source: [npm, main] + shard: ['1/6', '2/6', '3/6', '4/6', '5/6', '6/6'] steps: - uses: actions/checkout@v6 with: @@ -70,7 +71,7 @@ jobs: CDK_REPO: ${{ secrets.CDK_REPO_NAME }} - name: Install CLI globally run: npm install -g "$(npm pack | tail -1)" - - name: Run E2E tests (${{ matrix.cdk-source }}) + - name: Run E2E tests (${{ matrix.cdk-source }}, shard ${{ matrix.shard }}) env: AWS_ACCOUNT_ID: ${{ steps.aws.outputs.account_id }} AWS_REGION: ${{ inputs.aws_region || 'us-east-1' }} @@ -78,4 +79,49 @@ jobs: OPENAI_API_KEY: ${{ env.E2E_OPENAI_API_KEY }} GEMINI_API_KEY: ${{ env.E2E_GEMINI_API_KEY }} CDK_TARBALL: ${{ env.CDK_TARBALL }} - run: npm run test:e2e + run: npx vitest run --project e2e --shard=${{ matrix.shard }} + browser-tests: + runs-on: ubuntu-latest + environment: e2e-testing + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'main' }} + - uses: actions/setup-node@v6 + with: + node-version: '20.x' + cache: 'npm' + - name: Configure git + run: | + git config --global user.email "ci@amazon.com" + git config --global user.name "CI" + - uses: astral-sh/setup-uv@v7 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.E2E_AWS_ROLE_ARN }} + aws-region: ${{ inputs.aws_region || 'us-east-1' }} + - name: Get AWS Account ID + id: aws + run: echo "account_id=$(aws sts get-caller-identity --query Account --output text)" >> "$GITHUB_OUTPUT" + - run: npm ci + - run: npm run build + - name: Install CLI globally + run: npm install -g "$(npm pack | tail -1)" + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + - name: Run browser tests + env: + AWS_ACCOUNT_ID: ${{ steps.aws.outputs.account_id }} + AWS_REGION: ${{ inputs.aws_region || 'us-east-1' }} + PLAYWRIGHT_TRACE: 'off' + run: npm run test:browser + - name: Print browser test debug info + if: failure() + run: | + echo "=== Dev server PTY output ===" + cat browser-tests/test-results/agentcore-dev-pty.log 2>/dev/null || echo "(no pty log)" + echo "" + echo "=== Error contexts ===" + find browser-tests/test-results -name 'error-context.md' -exec echo "--- {} ---" \; -exec cat {} \; 2>/dev/null || echo "(no error contexts)" diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index eb93e98c4..e5f4aa9e2 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -47,14 +47,11 @@ jobs: runs-on: ubuntu-latest environment: e2e-testing timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - cdk-source: [npm, main] steps: - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: '20.x' @@ -82,7 +79,6 @@ jobs: # Build @aws/agentcore-cdk from source for cross-package testing. # Requires secrets: CDK_REPO_NAME (org/repo), CDK_REPO_TOKEN (fine-grained PAT) - name: Build CDK package from main - if: matrix.cdk-source == 'main' run: | git clone --depth 1 "https://x-access-token:${CDK_REPO_TOKEN}@github.com/${CDK_REPO}.git" /tmp/cdk-repo cd /tmp/cdk-repo @@ -98,7 +94,18 @@ jobs: - run: npm run build - name: Install CLI globally run: npm install -g "$(npm pack | tail -1)" - - name: Run E2E tests (${{ matrix.cdk-source }}) + + - name: Detect changed e2e test files + id: changed + run: | + BASE_SHA=${{ github.event.pull_request.base.sha || 'HEAD~1' }} + CHANGED=$(git diff --name-only "$BASE_SHA"..HEAD -- 'e2e-tests/*.test.ts' \ + | grep -v '^e2e-tests/strands-bedrock\.test\.ts$' \ + | tr '\n' ' ') + echo "extra_tests=$CHANGED" >> "$GITHUB_OUTPUT" + echo "Changed e2e tests: ${CHANGED:-none}" + + - name: Run E2E tests env: AWS_ACCOUNT_ID: ${{ steps.aws.outputs.account_id }} AWS_REGION: ${{ inputs.aws_region || 'us-east-1' }} @@ -106,6 +113,5 @@ jobs: OPENAI_API_KEY: ${{ env.E2E_OPENAI_API_KEY }} GEMINI_API_KEY: ${{ env.E2E_GEMINI_API_KEY }} CDK_TARBALL: ${{ env.CDK_TARBALL }} - # Only run Bedrock tests on PRs to avoid creating ApiKeyCredentialProviders, - # which have a 50-resource account limit and accumulate from interrupted runs. - run: npx vitest run --project e2e strands-bedrock langgraph-bedrock + # Always run strands-bedrock as baseline, plus any e2e test files changed in the PR + run: npx vitest run --project e2e e2e-tests/strands-bedrock.test.ts ${{ steps.changed.outputs.extra_tests }} diff --git a/.github/workflows/pr-ai-review.yml b/.github/workflows/pr-ai-review.yml new file mode 100644 index 000000000..26878d7a1 --- /dev/null +++ b/.github/workflows/pr-ai-review.yml @@ -0,0 +1,159 @@ +name: AgentCore Harness Reviewing + +on: + pull_request_target: + types: [opened, reopened] + workflow_dispatch: + inputs: + pr_url: + description: 'GitHub PR URL to review (e.g. https://github.com/org/repo/pull/123)' + required: true + type: string + +permissions: + id-token: write + pull-requests: write + contents: read + +jobs: + authorize: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request_target' + outputs: + authorized: ${{ steps.auth.outputs.authorized }} + steps: + - name: Check authorization + id: auth + if: github.event_name == 'pull_request_target' + uses: actions/github-script@v9 + with: + script: | + const user = context.payload.pull_request.user.login; + try { + // Try team membership first (works for org repos) + await github.rest.teams.getMembershipForUserInOrg({ + org: context.repo.owner, + team_slug: 'agentcore-cli-devs', + username: user, + }); + console.log(`${user} is a member of agentcore-cli-devs`); + core.setOutput('authorized', 'true'); + } catch (teamError) { + // Fall back to collaborator write access (works for personal repos) + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: user, + }); + const hasWriteAccess = ['write', 'admin'].includes(data.permission); + if (hasWriteAccess) { + console.log(`${user} has write access (${data.permission})`); + core.setOutput('authorized', 'true'); + } else { + console.log(`${user} does not have write access (${data.permission}) — skipping review`); + core.setOutput('authorized', 'false'); + } + } catch (collabError) { + console.log(`${user} authorization check failed (${collabError.status}) — skipping review`); + core.setOutput('authorized', 'false'); + } + } + + - name: Auto-authorize workflow_dispatch + id: dispatch-auth + if: github.event_name == 'workflow_dispatch' + run: echo "authorized=true" >> "$GITHUB_OUTPUT" + + ai-review: + needs: authorize + if: needs.authorize.outputs.authorized == 'true' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Determine PR URL + id: pr-url + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "url=${{ inputs.pr_url }}" >> "$GITHUB_OUTPUT" + else + echo "url=${{ github.event.pull_request.html_url }}" >> "$GITHUB_OUTPUT" + fi + + - name: Extract PR number + id: pr-number + run: | + PR_URL="${{ steps.pr-url.outputs.url }}" + PR_NUM="${PR_URL##*/}" + echo "number=$PR_NUM" >> "$GITHUB_OUTPUT" + + - name: Add agentcore-harness-reviewing label + uses: actions/github-script@v9 + with: + script: | + const prNumber = parseInt('${{ steps.pr-number.outputs.number }}'); + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'agentcore-harness-reviewing', + }); + } catch (e) { + if (e.status === 404) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'agentcore-harness-reviewing', + color: '7B61FF', + description: 'AgentCore Harness review in progress', + }); + } + } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['agentcore-harness-reviewing'], + }); + + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.HARNESS_AWS_ROLE_ARN }} + aws-region: us-east-1 + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install uv and dependencies + uses: astral-sh/setup-uv@v7 + + - name: Install boto3 + run: uv pip install --system boto3 + + - name: Run AI review + env: + PR_URL: ${{ steps.pr-url.outputs.url }} + HARNESS_ARN: ${{ secrets.HARNESS_ARN }} + run: python .github/harness/harness_review.py + + - name: Remove agentcore-harness-reviewing label + if: always() + uses: actions/github-script@v9 + with: + script: | + const prNumber = parseInt('${{ steps.pr-number.outputs.number }}'); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'agentcore-harness-reviewing', + }); + } catch (error) { + console.log('Label removal failed (may not exist):', error.message); + } diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 1982c05fd..792f0d728 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Calculate PR size and apply label - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const prNumber = context.payload.pull_request.number; diff --git a/.github/workflows/release-main-and-preview.yml b/.github/workflows/release-main-and-preview.yml new file mode 100644 index 000000000..b3cfb5ee2 --- /dev/null +++ b/.github/workflows/release-main-and-preview.yml @@ -0,0 +1,496 @@ +name: Release Both (Main + Preview) + +on: + workflow_dispatch: + inputs: + main_bump_type: + description: 'Main branch version bump' + required: true + type: choice + options: + - patch + - minor + - major + preview_bump_type: + description: 'Preview branch version bump (prerelease with preview tag)' + required: true + type: choice + options: + - prerelease + main_changelog: + description: 'Main changelog entry (optional)' + required: false + type: string + preview_changelog: + description: 'Preview changelog entry (optional)' + required: false + type: string + dry_run: + description: 'Dry run — create PRs but skip npm publish' + required: false + type: boolean + default: false + +permissions: + contents: write + pull-requests: write + +jobs: + # ═══════════════════════════════════════════════════════════════════ + # Preflight — verify preview contains all of main + # ═══════════════════════════════════════════════════════════════════ + preflight: + name: Preflight Checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Verify running from main + run: | + if [[ "${{ github.ref }}" != "refs/heads/main" ]]; then + echo "❌ This workflow must be run from the main branch." + exit 1 + fi + + - name: Verify preview contains all of main + run: | + git fetch origin preview + MAIN_SHA=$(git rev-parse HEAD) + MERGE_BASE=$(git merge-base HEAD origin/preview) + + if [[ "$MAIN_SHA" != "$MERGE_BASE" ]]; then + echo "❌ preview branch does not contain all of main." + echo "" + echo "Main HEAD: $MAIN_SHA" + echo "Merge base: $MERGE_BASE" + echo "" + echo "The sync-preview workflow should have merged automatically." + echo "If it failed due to conflicts, resolve manually:" + echo " git checkout preview && git merge main && git push origin preview" + echo "" + echo "Then re-run this workflow." + exit 1 + fi + + echo "✅ preview contains all of main" + + # ═══════════════════════════════════════════════════════════════════ + # Step 1 — Prepare main release (bump, PR) + # ═══════════════════════════════════════════════════════════════════ + prepare-main: + name: Prepare Main Release + needs: preflight + runs-on: ubuntu-latest + outputs: + version: ${{ steps.bump.outputs.version }} + branch: ${{ steps.bump.outputs.branch }} + + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: 20.x + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - run: npm ci + + - name: Bump version + id: bump + env: + BUMP_TYPE: ${{ github.event.inputs.main_bump_type }} + CHANGELOG_INPUT: ${{ github.event.inputs.main_changelog }} + run: | + BUMP_CMD="npx tsx scripts/bump-version.ts $BUMP_TYPE" + if [ -n "$CHANGELOG_INPUT" ]; then + BUMP_CMD="$BUMP_CMD --changelog \"$CHANGELOG_INPUT\"" + fi + eval $BUMP_CMD + + NEW_VERSION=$(node -p "require('./package.json').version") + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "branch=release/v$NEW_VERSION" >> $GITHUB_OUTPUT + echo "📦 Main version: $NEW_VERSION" + + - name: Regenerate JSON schema + run: | + npm run build + node scripts/generate-schema.mjs + npx prettier --write schemas/ + + - name: Update snapshots + run: npm run test:update-snapshots + + - name: Create release branch and PR + env: + GH_TOKEN: ${{ github.token }} + NEW_VERSION: ${{ steps.bump.outputs.version }} + run: | + BRANCH_NAME="release/v$NEW_VERSION" + git ls-remote --exit-code --heads origin $BRANCH_NAME && git push origin --delete $BRANCH_NAME || true + git show-ref --verify --quiet refs/heads/$BRANCH_NAME && git branch -D $BRANCH_NAME || true + + git checkout -b $BRANCH_NAME + git add -A + git commit -m "chore: bump version to $NEW_VERSION" + git push origin $BRANCH_NAME + + gh pr create \ + --base main \ + --head "$BRANCH_NAME" \ + --title "Release v$NEW_VERSION" \ + --body "## Release v$NEW_VERSION (main) + + Part of a coordinated main + preview release. + + ### Checklist + - [ ] Review CHANGELOG.md + - [ ] All CI checks passing + - [ ] Merge this PR before approving the publish step" + + # ═══════════════════════════════════════════════════════════════════ + # Step 2 — Prepare preview release (bump, PR) + # ═══════════════════════════════════════════════════════════════════ + prepare-preview: + name: Prepare Preview Release + needs: preflight + runs-on: ubuntu-latest + outputs: + version: ${{ steps.bump.outputs.version }} + branch: ${{ steps.bump.outputs.branch }} + + steps: + - name: Checkout preview + uses: actions/checkout@v6 + with: + ref: preview + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: 20.x + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - run: npm ci + + - name: Bump version + id: bump + env: + CHANGELOG_INPUT: ${{ github.event.inputs.preview_changelog }} + run: | + BUMP_CMD="npx tsx scripts/bump-version.ts prerelease --prerelease-tag preview" + if [ -n "$CHANGELOG_INPUT" ]; then + BUMP_CMD="$BUMP_CMD --changelog \"$CHANGELOG_INPUT\"" + fi + eval $BUMP_CMD + + NEW_VERSION=$(node -p "require('./package.json').version") + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "branch=release/v$NEW_VERSION" >> $GITHUB_OUTPUT + echo "📦 Preview version: $NEW_VERSION" + + - name: Regenerate JSON schema + run: | + npm run build + node scripts/generate-schema.mjs + npx prettier --write schemas/ + + - name: Update snapshots + run: npm run test:update-snapshots + + - name: Create release branch and PR + env: + GH_TOKEN: ${{ github.token }} + NEW_VERSION: ${{ steps.bump.outputs.version }} + run: | + BRANCH_NAME="release/v$NEW_VERSION" + git ls-remote --exit-code --heads origin $BRANCH_NAME && git push origin --delete $BRANCH_NAME || true + git show-ref --verify --quiet refs/heads/$BRANCH_NAME && git branch -D $BRANCH_NAME || true + + git checkout -b $BRANCH_NAME + git add -A + git commit -m "chore: bump version to $NEW_VERSION" + git push origin $BRANCH_NAME + + gh pr create \ + --base preview \ + --head "$BRANCH_NAME" \ + --title "Release v$NEW_VERSION (preview)" \ + --body "## Release v$NEW_VERSION (preview) + + Part of a coordinated main + preview release. + + ### Checklist + - [ ] Review CHANGELOG.md + - [ ] All CI checks passing + - [ ] Merge this PR before approving the publish step" + + # ═══════════════════════════════════════════════════════════════════ + # Step 3 — Build and test both + # ═══════════════════════════════════════════════════════════════════ + test-main: + name: Test Main + needs: prepare-main + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: release/v${{ needs.prepare-main.outputs.version }} + - uses: actions/setup-node@v6 + with: + node-version: 20.x + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + - run: curl -LsSf https://astral.sh/uv/install.sh | sh + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm run build + - run: npm run test:unit + + test-preview: + name: Test Preview + needs: prepare-preview + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: release/v${{ needs.prepare-preview.outputs.version }} + - uses: actions/setup-node@v6 + with: + node-version: 20.x + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + - run: curl -LsSf https://astral.sh/uv/install.sh | sh + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm run build + - run: npm run test:unit + + # ═══════════════════════════════════════════════════════════════════ + # Step 4 — Manual approval gate + # ═══════════════════════════════════════════════════════════════════ + release-approval: + name: Release Approval (Both) + needs: [test-main, test-preview, prepare-main, prepare-preview] + runs-on: ubuntu-latest + environment: + name: npm-publish-approval + steps: + - name: Approval checkpoint + env: + MAIN_VERSION: ${{ needs.prepare-main.outputs.version }} + PREVIEW_VERSION: ${{ needs.prepare-preview.outputs.version }} + run: | + echo "✅ Both builds and tests passed" + echo "" + echo "📦 Main version: $MAIN_VERSION (npm tag: latest)" + echo "📦 Preview version: $PREVIEW_VERSION (npm tag: preview)" + echo "" + echo "⚠️ MANUAL APPROVAL REQUIRED" + echo "" + echo "Before approving:" + echo "1. Merge the main release PR (release/v$MAIN_VERSION → main)" + echo "2. Merge the preview release PR (release/v$PREVIEW_VERSION → preview)" + echo "3. Verify both PRs are merged" + + # ═══════════════════════════════════════════════════════════════════ + # Step 5 — Verify both PRs merged before any publish + # ═══════════════════════════════════════════════════════════════════ + verify-merges: + name: Verify Both PRs Merged + needs: [prepare-main, prepare-preview, release-approval] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Verify main version + env: + EXPECTED: ${{ needs.prepare-main.outputs.version }} + run: | + git fetch origin main + ACTUAL=$(git show origin/main:package.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).version") + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "❌ Main release PR not merged yet!" + echo "Expected: $EXPECTED, Got: $ACTUAL" + exit 1 + fi + echo "✅ Main version verified: $ACTUAL" + + - name: Verify preview version + env: + EXPECTED: ${{ needs.prepare-preview.outputs.version }} + run: | + git fetch origin preview + ACTUAL=$(git show origin/preview:package.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).version") + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "❌ Preview release PR not merged yet!" + echo "Expected: $EXPECTED, Got: $ACTUAL" + exit 1 + fi + echo "✅ Preview version verified: $ACTUAL" + + # ═══════════════════════════════════════════════════════════════════ + # Step 6a — Publish main to npm (tag: latest) + # ═══════════════════════════════════════════════════════════════════ + publish-main: + name: Publish Main (@latest) + needs: [prepare-main, verify-merges] + runs-on: ubuntu-latest + environment: + name: npm-publish + url: https://www.npmjs.com/package/@aws/agentcore + permissions: + id-token: write + contents: write + + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: 22.x + registry-url: 'https://registry.npmjs.org' + + - run: npm install -g npm@11.5.1 + - run: npm ci + - run: npm run build + + - name: Publish to npm + run: npm publish --access public --provenance --tag latest + + - name: Tag and release + env: + VERSION: ${{ needs.prepare-main.outputs.version }} + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v$VERSION" -m "Release v$VERSION" + git push origin "v$VERSION" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.prepare-main.outputs.version }} + name: AgentCore CLI v${{ needs.prepare-main.outputs.version }} + generate_release_notes: true + prerelease: false + body: | + ## Installation + + ```bash + npm install -g @aws/agentcore@${{ needs.prepare-main.outputs.version }} + ``` + + # ═══════════════════════════════════════════════════════════════════ + # Step 6b — Publish preview to npm (tag: preview) + # ═══════════════════════════════════════════════════════════════════ + publish-preview: + name: Publish Preview (@preview) + needs: [prepare-preview, verify-merges] + runs-on: ubuntu-latest + environment: + name: npm-publish + url: https://www.npmjs.com/package/@aws/agentcore + permissions: + id-token: write + contents: write + + steps: + - name: Checkout preview + uses: actions/checkout@v6 + with: + ref: preview + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: 22.x + registry-url: 'https://registry.npmjs.org' + + - run: npm install -g npm@11.5.1 + - run: npm ci + - run: npm run build + + - name: Publish to npm + run: npm publish --access public --provenance --tag preview + + - name: Tag and release + env: + VERSION: ${{ needs.prepare-preview.outputs.version }} + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v$VERSION" -m "Release v$VERSION (preview)" + git push origin "v$VERSION" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ needs.prepare-preview.outputs.version }} + name: AgentCore CLI v${{ needs.prepare-preview.outputs.version }} (Preview) + generate_release_notes: true + prerelease: true + body: | + ## Installation (Preview) + + ```bash + npm install -g @aws/agentcore@preview + ``` + + # ═══════════════════════════════════════════════════════════════════ + # Summary + # ═══════════════════════════════════════════════════════════════════ + summary: + name: Release Summary + needs: [prepare-main, prepare-preview, publish-main, publish-preview] + if: always() + runs-on: ubuntu-latest + steps: + - name: Summary + env: + MAIN_VERSION: ${{ needs.prepare-main.outputs.version }} + PREVIEW_VERSION: ${{ needs.prepare-preview.outputs.version }} + MAIN_STATUS: ${{ needs.publish-main.result }} + PREVIEW_STATUS: ${{ needs.publish-preview.result }} + run: | + echo "## Release Summary" + echo "" + echo "| Package | Version | npm Tag | Status |" + echo "|---------|---------|---------|--------|" + echo "| @aws/agentcore | $MAIN_VERSION | latest | $MAIN_STATUS |" + echo "| @aws/agentcore | $PREVIEW_VERSION | preview | $PREVIEW_STATUS |" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21afc4e1f..be8e6122f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -377,7 +377,7 @@ jobs: git push origin "v$VERSION" - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 env: VERSION: ${{ steps.version.outputs.version }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/.github/workflows/slack-open-prs-notification.yml b/.github/workflows/slack-open-prs-notification.yml index 82330527b..68dd1df49 100644 --- a/.github/workflows/slack-open-prs-notification.yml +++ b/.github/workflows/slack-open-prs-notification.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Get open PRs id: open-prs - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const { data: prs } = await github.rest.pulls.list({ diff --git a/.github/workflows/strands-command.yml b/.github/workflows/strands-command.yml index bfba8c80d..a4e20a16b 100644 --- a/.github/workflows/strands-command.yml +++ b/.github/workflows/strands-command.yml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check authorization - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | // Skip auth check for workflow_dispatch (manual runs) @@ -70,7 +70,7 @@ jobs: fetch-depth: 0 - name: Add strands-running label - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const issueNumber = ${{ inputs.issue_id || github.event.issue.number || github.event.pull_request.number }}; @@ -83,7 +83,7 @@ jobs: - name: Process inputs and build prompts id: process-inputs - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const processInputs = require('./.github/scripts/javascript/process-inputs.cjs'); @@ -112,7 +112,7 @@ jobs: - name: Remove strands-running label if: always() - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | try { diff --git a/.github/workflows/sync-preview.yml b/.github/workflows/sync-preview.yml new file mode 100644 index 000000000..61abcbc86 --- /dev/null +++ b/.github/workflows/sync-preview.yml @@ -0,0 +1,125 @@ +name: Sync Preview with Main + +on: + push: + branches: [main] + +concurrency: + group: sync-preview + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + name: Merge main into preview + runs-on: ubuntu-latest + steps: + - name: Checkout preview + uses: actions/checkout@v6 + with: + ref: preview + fetch-depth: 0 + + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Merge main into preview + id: merge + run: | + git fetch origin main + MAIN_SHA=$(git rev-parse origin/main) + MERGE_BASE=$(git merge-base HEAD origin/main) + + if [[ "$MAIN_SHA" == "$MERGE_BASE" ]]; then + echo "✅ preview already contains all of main" + echo "status=up-to-date" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "ℹ️ Merging main into preview..." + + if git merge origin/main --no-edit -m "chore: merge main into preview"; then + git push origin preview + echo "✅ main merged into preview and pushed" + echo "status=merged" >> $GITHUB_OUTPUT + else + git merge --abort + echo "status=conflict" >> $GITHUB_OUTPUT + fi + + - name: Get original commit author + if: steps.merge.outputs.status == 'conflict' + id: author + run: | + AUTHOR=$(git log origin/main -1 --format='%an') + GH_USER=$(git log origin/main -1 --format='%ae' | grep -oP '.*(?=@users\.noreply\.github\.com)' || echo "") + if [[ -z "$GH_USER" ]]; then + # Try to get GitHub username from the commit + GH_USER=$(gh api "/repos/${{ github.repository }}/commits/$(git rev-parse origin/main)" --jq '.author.login // empty' 2>/dev/null || echo "") + fi + echo "name=$AUTHOR" >> $GITHUB_OUTPUT + echo "gh_user=$GH_USER" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ github.token }} + + - name: Create PR for conflict resolution + if: steps.merge.outputs.status == 'conflict' + env: + GH_TOKEN: ${{ github.token }} + AUTHOR_NAME: ${{ steps.author.outputs.name }} + AUTHOR_GH: ${{ steps.author.outputs.gh_user }} + run: | + BRANCH="sync-preview/merge-main-$(date +%Y%m%d-%H%M%S)" + + # Check if there's already an open sync PR + EXISTING=$(gh pr list --base preview --search "sync-preview: merge main into preview" --state open --json number --jq 'length') + if [[ "$EXISTING" != "0" ]]; then + echo "ℹ️ Sync PR already open — skipping duplicate." + exit 0 + fi + + # Create a branch from preview with the conflict markers + git checkout -b "$BRANCH" + git merge origin/main --no-edit -m "chore: merge main into preview" || true + git add -A + git commit --no-edit -m "chore: merge main into preview (conflicts need resolution)" || true + git push origin "$BRANCH" + + # Build mention string + MENTION="" + if [[ -n "$AUTHOR_GH" ]]; then + MENTION="cc @${AUTHOR_GH}" + fi + + gh pr create \ + --base preview \ + --head "$BRANCH" \ + --title "sync-preview: merge main into preview" \ + --body "$(cat < + \`\`\` + 2. Search for conflict markers and resolve them: + \`\`\`bash + grep -rn '<<<<<<< HEAD' . + \`\`\` + 3. Keep preview-specific values (package version, preview tests, etc.) — accept main's changes for everything else. + 4. Commit and push, then merge this PR. + + This must be resolved before the next coordinated release. + + ${MENTION} + + _Opened automatically by the sync-preview workflow._ + BODY + )" diff --git a/CHANGELOG.md b/CHANGELOG.md index ec13d3ab0..679d3d4b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ All notable changes to this project will be documented in this file. +## [0.13.1] - 2026-05-06 + +### Added +- feat: add archive command for batch evaluations and recommendations (#1112) (7586092e) + +### Fixed +- fix: correct AB test execution role IAM policy and promote stability (#1120) (9f231d00) +- fix: set iamRoleFallback to true for lambda gateway targets (#1086) (639adf1b) +- fix: prefix HTTP gateway names with project name to prevent cross-project collisions (#1105) (e9066ce0) +- fix: use correct resourceType for config bundle in E2E status test (#1094) (7fb8a636) +- fix: align E2E batch eval and recommendation tests with current API (#1093) (f1d046cf) +- fix: sync e2e IAM policy and fix run eval flag (#1092) (78b3bd15) +- fix: address formatting failure in docs (#1080) (162afd45) + +### Documentation +- docs: clarify integration vs e2e test boundaries and add e2e README (#1111) (bb69aa53) +- docs: remove CrewAI from supported frameworks (#1059) (a91d8882) + +### Other Changes +- test: collapse schema enumeration tests and remove duplicates (#1087) (4f464d77) +- test: remove http-gateway-targets e2e test (#1090) (5ce18744) +- chore(deps): override glob to ^13 to silence install deprecation warning (#1008) (3b7a0a5b) + +## [0.13.0] - 2026-05-01 + +### Added +- feat: evo preview features — config bundles, batch evaluation, recommendations, AB testing (#1068) (9ccf802) +- feat: wire telemetry into all add.* commands (#1050) (e9dfc16) +- feat: make parsing resilient to individual failures (#1062) (a4c37a2) +- feat: update @aws/agent-inspector to 0.3.0 (90f17b4) +- feat: update @aws/agent-inspector to 0.3.0 (278783a) + +### Fixed +- fix: remove unnecessary non-null assertions after .default([]) revert (#1075) (eab8c87) +- fix: revert .optional() to .default([]) and strip empty evo arrays on write (#1074) (8c5cdfe) +- fix: remove dead preflight patch, proper teardown, optional evo schema fields (#1073) (839b32b) +- fix: remove dead preflight patch and use proper teardown for evo resources (#1072) (0e38e9e) +- fix: resolve e2e import test concurrency races (#1067) (bd6f841) +- fix: forward custom headers in bearer token invoke paths (#1065) (3dccd97) + +### Other Changes +- refactor: consolidate cli-config into global-config (#802) (3aec000) +- ci: cut full e2e time in half via vitest sharding (#1016) (4daca83) + ## [0.11.0] - 2026-04-24 ### Added diff --git a/README.md b/README.md index 27dc204ce..4abc7a033 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,6 @@ agentcore invoke | ------------------- | ----------------------------- | | Strands Agents | AWS-native, streaming support | | LangChain/LangGraph | Graph-based workflows | -| CrewAI | Multi-agent orchestration | | Google ADK | Gemini models only | | OpenAI Agents | OpenAI models only | @@ -110,15 +109,29 @@ agentcore invoke ### Evaluations -| Command | Description | -| -------------------- | --------------------------------------------- | -| `add evaluator` | Add a custom LLM-as-a-Judge evaluator | -| `add online-eval` | Add continuous evaluation for live traffic | -| `run eval` | Run on-demand evaluation against agent traces | -| `evals history` | View past eval run results | -| `pause online-eval` | Pause a deployed online eval config | -| `resume online-eval` | Resume a paused online eval config | -| `logs evals` | Stream or search online eval logs | +| Command | Description | +| ----------------------- | ------------------------------------------------ | +| `add evaluator` | Add a custom LLM-as-a-Judge evaluator | +| `add online-eval` | Add continuous evaluation for live traffic | +| `run eval` | Run on-demand evaluation against agent traces | +| `run batch-evaluation` | Run evaluators across all sessions [preview] | +| `run recommendation` | Optimize prompts and tool descriptions [preview] | +| `evals history` | View past eval run results | +| `pause online-eval` | Pause a deployed online eval config | +| `resume online-eval` | Resume a paused online eval config | +| `stop batch-evaluation` | Stop a running batch evaluation [preview] | +| `logs evals` | Stream or search online eval logs | + +### Config Bundles [preview] + +| Command | Description | +| ------------------- | ----------------------------------------- | +| `add config-bundle` | Add a versioned configuration bundle | +| `cb versions` | List version history for a bundle | +| `cb diff` | Diff two versions of a bundle | +| `cb create-branch` | Create a new branch on an existing bundle | + +> Create agents with `--with-config-bundle` to auto-wire config bundle support into the generated template. ### Utilities @@ -171,6 +184,9 @@ Projects use JSON schema files in the `agentcore/` directory: - [CLI Commands Reference](docs/commands.md) - Full command reference for scripting and CI/CD - [Configuration](docs/configuration.md) - Schema reference for config files - [Evaluations](docs/evals.md) - Evaluators, on-demand evals, and online monitoring +- [Batch Evaluation](docs/batch-evaluation.md) - Run evaluators across sessions at scale [preview] +- [Recommendations](docs/recommendations.md) - Optimize prompts and tool descriptions [preview] +- [Config Bundles](docs/config-bundles.md) - Versioned runtime configurations [preview] - [Frameworks](docs/frameworks.md) - Supported frameworks and model providers - [Gateway](docs/gateway.md) - Gateway setup, targets, and authentication - [Memory](docs/memory.md) - Memory strategies and sharing diff --git a/docs/TESTING.md b/docs/TESTING.md index 700ab3aae..601cf258f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -39,6 +39,21 @@ integ-tests/ See [integ-tests/README.md](../integ-tests/README.md) for integration test details. +### E2E Tests + +E2E tests live in `e2e-tests/` and verify the full user journey across the AWS boundary — deploy, invoke, status, logs, +traces, and control plane API calls. + +``` +e2e-tests/ +├── e2e-helper.ts # Shared utilities and createE2ESuite() factory +├── strands-bedrock.test.ts +├── langgraph-openai.test.ts +└── ... +``` + +See [e2e-tests/README.md](../e2e-tests/README.md) for e2e test details. + ## Writing Tests ### Imports @@ -415,16 +430,32 @@ Test configuration is in `vitest.config.ts` using Vitest projects: - Test timeout: 120 seconds - Hook timeout: 120 seconds -## Integration Tests +## Troubleshooting + +### `Cannot find module '@playwright/test'` + +Playwright is not installed. Run: -Integration tests require: +```bash +npm install +``` + +### `browserType.launch: Executable doesn't exist` (Playwright browsers) + +Playwright browsers need to be downloaded after install. Run: + +```bash +npx playwright install chromium +``` -- AWS credentials configured -- IAM permissions for CloudFormation operations -- Dedicated test AWS account (recommended) +## Integration Tests + +Integration tests require no AWS credentials. They run the real CLI binary and assert on local files and stdout only. Run integration tests: ```bash npm run test:integ ``` + +See [integ-tests/README.md](../integ-tests/README.md) for full details. diff --git a/docs/batch-evaluation.md b/docs/batch-evaluation.md new file mode 100644 index 000000000..ea13d3707 --- /dev/null +++ b/docs/batch-evaluation.md @@ -0,0 +1,143 @@ +# Batch Evaluation [preview] + +Batch evaluation runs evaluators across all agent sessions in CloudWatch, producing per-session scores and aggregate +metrics. Use it to measure agent quality over time, compare before/after prompt changes, or validate ground truth +expectations. + +## Quick Start + +```bash +# Run a single evaluator across all sessions +agentcore run batch-evaluation -r MyAgent -e Builtin.Correctness + +# Multiple evaluators +agentcore run batch-evaluation -r MyAgent -e Builtin.Correctness Builtin.Helpfulness Builtin.Faithfulness + +# JSON output for scripting +agentcore run batch-evaluation -r MyAgent -e Builtin.Helpfulness --json +``` + +## Available Evaluators + +Built-in evaluators provided by AgentCore: + +| Evaluator | What it measures | +| ----------------------------------- | ---------------------------------------------- | +| `Builtin.Correctness` | Factual accuracy of responses | +| `Builtin.Helpfulness` | How well responses address the user's goal | +| `Builtin.Faithfulness` | Grounding in tool results / provided context | +| `Builtin.GoalSuccessRate` | Whether the agent achieved the user's goal | +| `Builtin.ToolSelectionAccuracy` | Correct tool chosen for the task | +| `Builtin.Completeness` | Whether all parts of the request were handled | +| `Builtin.TrajectoryExactOrderMatch` | Tool call sequence matches expected trajectory | + +Custom evaluators defined in your project (via `agentcore add evaluator`) can also be used. + +## Filtering Sessions + +### By time window + +```bash +# Only sessions from the last 3 days +agentcore run batch-evaluation -r MyAgent -e Builtin.Helpfulness --lookback-days 3 +``` + +### By session ID + +```bash +agentcore run batch-evaluation -r MyAgent -e Builtin.Correctness -s +``` + +## Ground Truth + +Provide expected responses, assertions, or expected tool trajectories for specific sessions: + +```bash +agentcore run batch-evaluation \ + -r MyAgent \ + -e Builtin.Correctness Builtin.GoalSuccessRate \ + -s \ + --ground-truth ./ground_truth.json +``` + +### Ground truth file format + +```json +[ + { + "sessionId": "", + "groundTruth": { + "inline": { + "assertions": [{ "text": "Agent should use the lookup_order tool" }], + "expectedTrajectory": { + "toolNames": ["lookup_order"] + }, + "turns": [ + { + "input": "What's the status of order ORD-1001?", + "expectedResponse": { "text": "Order ORD-1001 has been delivered" } + } + ] + } + } + } +] +``` + +All fields inside `inline` are optional — include only what's relevant: + +- `assertions` — free-text expectations evaluated by `Builtin.GoalSuccessRate` +- `expectedTrajectory` — tool call sequence evaluated by `Builtin.TrajectoryExactOrderMatch` +- `turns` — input/expected-response pairs evaluated by `Builtin.Correctness` + +## Custom Name + +```bash +agentcore run batch-evaluation -r MyAgent -e Builtin.Helpfulness -n "weekly_quality_check" +``` + +Names must start with a letter and contain only letters, digits, and underscores (max 48 characters). + +## Stopping a Running Evaluation + +```bash +agentcore stop batch-evaluation -i +``` + +## Viewing Results + +### CLI output + +The CLI shows scores grouped by evaluator with average scores after the run completes. + +### Local history + +Results are saved in `.cli/eval-job-results/`. View past runs via the TUI: + +```bash +agentcore +# Navigate to: Evals → Batch Evaluation History +``` + +### JSON output + +```bash +agentcore run batch-evaluation -r MyAgent -e Builtin.Helpfulness --json +``` + +Returns `batchEvaluationId`, `evaluationResults` with `numberOfSessionsCompleted`, `evaluatorSummaries` with +per-evaluator `averageScore`. + +## TUI Wizard + +Run `agentcore` → Run → Batch Evaluation for a guided flow: + +1. Select agent +2. Multi-select evaluators +3. Set lookback days +4. Optionally select specific sessions +5. Optionally add ground truth +6. Name the run (optional) +7. Confirm and run + +The TUI shows real-time progress with elapsed time and step indicators. diff --git a/docs/config-bundles.md b/docs/config-bundles.md new file mode 100644 index 000000000..890ad7aaf --- /dev/null +++ b/docs/config-bundles.md @@ -0,0 +1,114 @@ +# Configuration Bundles [preview] + +Config bundles are versioned configurations that store your agent's runtime settings — system prompt, tool descriptions, +model parameters, or any custom keys. Instead of hardcoding values in your agent code, your agent reads its config at +invocation time from whichever bundle version is active. + +## Concepts + +| Concept | Description | +| ------------- | ----------------------------------------------------------------------------------- | +| **Bundle** | A named container for component configurations, stored in `agentcore.json` | +| **Version** | An immutable snapshot of a bundle's configuration, created on each deploy or update | +| **Branch** | A named lineage within a bundle (e.g. `mainline`, `experiment-1`) | +| **Component** | A runtime or gateway whose configuration is managed by the bundle | + +## Creating a Config Bundle + +### With agent creation + +Create an agent with a pre-wired config bundle that injects system prompt and tool descriptions at runtime: + +```bash +agentcore create --name MyProject --defaults --with-config-bundle +``` + +This creates a `{AgentName}Config` bundle with smart defaults and generates a template that uses +`BedrockAgentCoreContext.get_config_bundle()` to read config at runtime. + +### Standalone + +```bash +agentcore add config-bundle \ + --name MyBundle \ + --description "Production configuration" \ + --components '{"{{runtime:MyAgent}}": {"configuration": {"systemPrompt": "You are helpful.", "temperature": 0.7}}}' \ + --branch mainline \ + --commit-message "Initial config" \ + --json +``` + +The `{{runtime:MyAgent}}` placeholder resolves to the real runtime ARN at deploy time. + +### Via TUI + +Run `agentcore` → Add → select "Configuration Bundle", or select "Config bundle" in the Advanced Configuration step when +adding an agent. + +## Deploying + +```bash +agentcore deploy +``` + +On deploy, the CLI creates or updates the config bundle in the API and stores the bundle ID, ARN, and version ID in +`deployed-state.json`. + +## Managing Versions + +### List versions + +```bash +agentcore cb versions --bundle MyBundle +``` + +Shows version history grouped by branch with commit messages, timestamps, and parent lineage. + +### Diff two versions + +```bash +agentcore cb diff --bundle MyBundle --from --to +``` + +### Create a branch + +```bash +agentcore cb create-branch --bundle MyBundle --branch experiment-1 +``` + +Creates a new branch from the latest version (or a specific version with `--from`). + +## Updating Without Redeploying Code + +Edit the `systemPrompt` or other fields in `agentcore.json` under `configBundles`, then: + +```bash +agentcore deploy +``` + +A new version is created in the API. The next invocation picks up the new config automatically — no code changes needed. + +## How It Works at Runtime + +When you invoke an agent with an associated config bundle, the CLI passes the bundle ARN and version as W3C baggage +headers. The SDK's `BedrockAgentCoreContext.get_config_bundle()` reads the baggage, fetches the config from the API +(cached per version), and makes it available to your agent code. + +The generated template uses a `ConfigBundleHook` (Strands) or `ConfigBundleCallback` (LangGraph) to inject the system +prompt and tool descriptions before each invocation. + +## Bundle Name in agentcore.json + +The CLI prefixes your bundle name with the project name when creating it in the API (e.g. `MyProject` + `MyBundle` → +`MyProjectMyBundle`). You always use the local name (`MyBundle`) in CLI commands — the CLI resolves the prefix +automatically. + +## JSON Output + +All commands support `--json` for scripting: + +```bash +agentcore cb versions --bundle MyBundle --json +agentcore cb diff --bundle MyBundle --from v1 --to v2 --json +agentcore cb create-branch --bundle MyBundle --branch exp-1 --json +``` diff --git a/docs/frameworks.md b/docs/frameworks.md index ec53a1354..7d2b8658a 100644 --- a/docs/frameworks.md +++ b/docs/frameworks.md @@ -9,7 +9,6 @@ for existing code. | ----------------------- | ---------------------------------- | | **Strands Agents** | Bedrock, Anthropic, OpenAI, Gemini | | **LangChain_LangGraph** | Bedrock, Anthropic, OpenAI, Gemini | -| **CrewAI** | Bedrock, Anthropic, OpenAI, Gemini | | **GoogleADK** | Gemini only | | **OpenAIAgents** | OpenAI only | @@ -62,21 +61,6 @@ Google's Agent Development Kit. agentcore create --framework GoogleADK --model-provider Gemini ``` -### CrewAI - -Multi-agent orchestration framework. - -**Best for:** - -- Multi-agent workflows with role-based collaboration -- Projects requiring agent coordination and task delegation - -**Model providers:** Bedrock, Anthropic, OpenAI, Gemini - -```bash -agentcore create --framework CrewAI --model-provider Bedrock -``` - ### OpenAIAgents OpenAI's native agent framework. @@ -167,19 +151,19 @@ agentcore add agent \ ## Framework Comparison -| Feature | Strands | LangChain | CrewAI | GoogleADK | OpenAIAgents | -| ---------------------- | ------- | --------- | -------- | --------- | ------------ | -| Multi-provider support | Yes | Yes | Yes | No | No | -| AWS Bedrock native | Yes | No | No | No | No | -| Tool ecosystem | Growing | Extensive | Moderate | Moderate | Moderate | -| Memory integration | Native | Via libs | Via libs | Via libs | Via libs | +| Feature | Strands | LangChain | GoogleADK | OpenAIAgents | +| ---------------------- | ------- | --------- | --------- | ------------ | +| Multi-provider support | Yes | Yes | No | No | +| AWS Bedrock native | Yes | No | No | No | +| Tool ecosystem | Growing | Extensive | Moderate | Moderate | +| Memory integration | Native | Via libs | Via libs | Via libs | ## Protocol Compatibility Not all frameworks support all protocol modes. MCP protocol is a standalone tool server with no framework. -| Protocol | Supported Frameworks | -| -------- | ------------------------------------------------------------- | -| **HTTP** | Strands, LangChain_LangGraph, CrewAI, GoogleADK, OpenAIAgents | -| **MCP** | None (standalone tool server) | -| **A2A** | Strands, GoogleADK, LangChain_LangGraph | +| Protocol | Supported Frameworks | +| -------- | ----------------------------------------------------- | +| **HTTP** | Strands, LangChain_LangGraph, GoogleADK, OpenAIAgents | +| **MCP** | None (standalone tool server) | +| **A2A** | Strands, GoogleADK, LangChain_LangGraph | diff --git a/docs/policies/iam-policy-user.json b/docs/policies/iam-policy-user.json index d2467a134..b7fa29118 100644 --- a/docs/policies/iam-policy-user.json +++ b/docs/policies/iam-policy-user.json @@ -62,6 +62,8 @@ "bedrock-agentcore:GetApiKeyCredentialProvider", "bedrock-agentcore:CreateApiKeyCredentialProvider", "bedrock-agentcore:UpdateApiKeyCredentialProvider", + "bedrock-agentcore:DeleteApiKeyCredentialProvider", + "bedrock-agentcore:ListApiKeyCredentialProviders", "bedrock-agentcore:GetOauth2CredentialProvider", "bedrock-agentcore:CreateOauth2CredentialProvider", "bedrock-agentcore:UpdateOauth2CredentialProvider", @@ -114,7 +116,7 @@ { "Sid": "BedrockModelInvocation", "Effect": "Allow", - "Action": "bedrock:InvokeModel", + "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"], "Resource": "*" }, { @@ -135,6 +137,169 @@ "s3:GetObject" ], "Resource": "*" + }, + { + "Sid": "AgentCoreResourceManagement", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:CreateAgentRuntime", + "bedrock-agentcore:UpdateAgentRuntime", + "bedrock-agentcore:DeleteAgentRuntime", + "bedrock-agentcore:ListAgentRuntimes", + "bedrock-agentcore:CreateAgentRuntimeEndpoint", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:DeleteWorkloadIdentity", + "bedrock-agentcore:CreateMemory", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:UpdateMemory", + "bedrock-agentcore:DeleteMemory", + "bedrock-agentcore:ListMemories", + "bedrock-agentcore:CreateEvaluator", + "bedrock-agentcore:DeleteEvaluator", + "bedrock-agentcore:ListOnlineEvaluationConfigs", + "bedrock-agentcore:TagResource", + "bedrock-agentcore:ListTagsForResource", + "bedrock-agentcore:CreateGateway", + "bedrock-agentcore:UpdateGateway", + "bedrock-agentcore:DeleteGateway", + "bedrock-agentcore:GetGateway", + "bedrock-agentcore:ListGateways", + "bedrock-agentcore:CreateGatewayTarget", + "bedrock-agentcore:UpdateGatewayTarget", + "bedrock-agentcore:DeleteGatewayTarget", + "bedrock-agentcore:GetGatewayTarget", + "bedrock-agentcore:SynchronizeGatewayTargets" + ], + "Resource": "*" + }, + { + "Sid": "CloudFormationFull", + "Effect": "Allow", + "Action": "cloudformation:*", + "Resource": "*" + }, + { + "Sid": "SsmParameterLookup", + "Effect": "Allow", + "Action": ["ssm:GetParameters", "ssm:GetParameter"], + "Resource": "*" + }, + { + "Sid": "CloudFormationTemplateVerification", + "Effect": "Allow", + "Action": "cloudformation:GetTemplate", + "Resource": "*" + }, + { + "Sid": "ImportTestIam", + "Effect": "Allow", + "Action": ["iam:GetRole", "iam:CreateRole", "iam:AttachRolePolicy", "iam:PutRolePolicy"], + "Resource": "arn:aws:iam::ACCOUNT_ID:role/bugbash-agentcore-role" + }, + { + "Sid": "ImportTestPassRole", + "Effect": "Allow", + "Action": "iam:PassRole", + "Resource": "arn:aws:iam::ACCOUNT_ID:role/bugbash-agentcore-role", + "Condition": { + "StringEquals": { + "iam:PassedToService": "bedrock-agentcore.amazonaws.com" + } + } + }, + { + "Sid": "ImportTestS3", + "Effect": "Allow", + "Action": ["s3:ListBucket", "s3:CreateBucket", "s3:PutObject"], + "Resource": "*" + }, + { + "Sid": "SecretsManager", + "Effect": "Allow", + "Action": ["secretsmanager:GetSecretValue", "secretsmanager:CreateSecret", "secretsmanager:DeleteSecret"], + "Resource": "*" + }, + { + "Sid": "CustomJwtCognitoSetup", + "Effect": "Allow", + "Action": [ + "cognito-idp:CreateUserPool", + "cognito-idp:CreateUserPoolDomain", + "cognito-idp:CreateResourceServer", + "cognito-idp:CreateUserPoolClient", + "cognito-idp:DeleteResourceServer", + "cognito-idp:DeleteUserPoolDomain", + "cognito-idp:DeleteUserPool" + ], + "Resource": "*" + }, + { + "Sid": "HarnessManagement", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:CreateHarness", + "bedrock-agentcore:GetHarness", + "bedrock-agentcore:UpdateHarness", + "bedrock-agentcore:DeleteHarness", + "bedrock-agentcore:ListHarnesses", + "bedrock-agentcore:InvokeHarness" + ], + "Resource": "*" + }, + { + "Sid": "HarnessPassRole", + "Effect": "Allow", + "Action": "iam:PassRole", + "Resource": "arn:aws:iam::ACCOUNT_ID:role/*", + "Condition": { + "StringEquals": { + "iam:PassedToService": "bedrock-agentcore.amazonaws.com" + } + } + }, + { + "Sid": "ConfigBundleManagement", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:CreateConfigurationBundle", + "bedrock-agentcore:UpdateConfigurationBundle", + "bedrock-agentcore:DeleteConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundleVersion", + "bedrock-agentcore:ListConfigurationBundles", + "bedrock-agentcore:ListConfigurationBundleVersions" + ], + "Resource": "*" + }, + { + "Sid": "HttpGatewayIamRoleManagement", + "Effect": "Allow", + "Action": [ + "iam:CreateRole", + "iam:DeleteRole", + "iam:GetRole", + "iam:PutRolePolicy", + "iam:DeleteRolePolicy", + "iam:TagRole", + "iam:PassRole" + ], + "Resource": "arn:aws:iam::*:role/AgentCore-*" + }, + { + "Sid": "BatchEvalAndRecommendation", + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:StartBatchEvaluation", + "bedrock-agentcore:GetBatchEvaluation", + "bedrock-agentcore:ListBatchEvaluations", + "bedrock-agentcore:StopBatchEvaluation", + "bedrock-agentcore:DeleteBatchEvaluation", + "bedrock-agentcore:StartRecommendation", + "bedrock-agentcore:GetRecommendation", + "bedrock-agentcore:ListRecommendations", + "bedrock-agentcore:DeleteRecommendation" + ], + "Resource": "*" } ] } diff --git a/docs/recommendations.md b/docs/recommendations.md new file mode 100644 index 000000000..c5a5c4ac3 --- /dev/null +++ b/docs/recommendations.md @@ -0,0 +1,158 @@ +# Recommendations [preview] + +Recommendations optimize your agent's system prompt or tool descriptions using historical traces as signal. The +recommendation service analyzes how your agent performed, then produces an improved version scored by an evaluator. + +## Quick Start + +```bash +# Optimize a system prompt (inline) +agentcore run recommendation \ + -r MyAgent \ + -e Builtin.Helpfulness \ + --type system-prompt \ + --inline "You are a helpful assistant." + +# Optimize tool descriptions +agentcore run recommendation \ + -r MyAgent \ + --type tool-description \ + --tools "search:Searches the web" "calc:Does math" +``` + +## System Prompt Recommendations + +### From inline text + +```bash +agentcore run recommendation \ + -r MyAgent \ + -e Builtin.Helpfulness \ + --type system-prompt \ + --inline "You are a helpful assistant. Use tools when appropriate." +``` + +### From a file + +```bash +agentcore run recommendation \ + -r MyAgent \ + -e Builtin.Helpfulness \ + --type system-prompt \ + --prompt-file ./system-prompt.txt +``` + +### From a config bundle + +Read the current prompt from a deployed config bundle, optimize it, and write the result back as a new bundle version: + +```bash +agentcore run recommendation \ + -r MyAgent \ + -e Builtin.Helpfulness \ + --type system-prompt \ + --bundle-name MyBundle \ + --bundle-version \ + --system-prompt-json-path systemPrompt +``` + +The `--system-prompt-json-path` is the field name under `configuration` in the bundle (e.g. `systemPrompt`). The CLI +resolves it to the full path automatically using the component ARN from your deployed state. + +> **JSONPath format:** The API uses dot notation (`$.{ARN}.configuration.{field}`), not standard JSONPath bracket +> notation. You don't need to worry about this — just pass the short field name and the CLI handles the resolution. If +> you need the full path for direct API calls, use `$.arn:aws:...:runtime/MyAgent.configuration.systemPrompt` (no +> brackets, no quotes around the ARN). + +On success, the recommendation writes a new config bundle version with the optimized prompt. The agent picks it up on +the next invocation — no redeploy needed. + +## Tool Description Recommendations + +```bash +agentcore run recommendation \ + -r MyAgent \ + --type tool-description \ + --tools "add_numbers:Return the sum of two numbers" "search:Searches the web" +``` + +Returns optimized tool descriptions for each tool. + +## Trace Source + +By default, the recommendation service fetches traces from CloudWatch using a 7-day lookback. Customize with: + +```bash +# Custom lookback window +agentcore run recommendation ... --lookback 14 + +# Specific sessions only +agentcore run recommendation ... --session-id + +# From a local spans file (OTEL format) +agentcore run recommendation ... --spans-file ./traces.json +``` + +## JSON Output + +```bash +agentcore run recommendation -r MyAgent -e Builtin.Helpfulness --type system-prompt --inline "..." --json +``` + +Returns `recommendationId`, `status`, and `result` with `systemPromptRecommendationResult.recommendedSystemPrompt` or +`toolDescriptionRecommendationResult.tools`. + +When using `--bundle-name`, the result also includes `configurationBundle.versionId` — the new bundle version. + +## End-to-End Workflow: Recommendation → Config Bundle → Invoke + +1. Create agent with config bundle: + + ```bash + agentcore create --name MyAgent --defaults --with-config-bundle + agentcore deploy + ``` + +2. Invoke a few times to generate traces: + + ```bash + agentcore invoke --prompt "What is 2 + 3?" + agentcore invoke --prompt "Tell me about Paris" + ``` + +3. Run recommendation from config bundle: + + ```bash + agentcore run recommendation \ + -r MyAgent -e Builtin.Helpfulness --type system-prompt \ + --bundle-name MyAgentConfig --bundle-version \ + --system-prompt-json-path systemPrompt + ``` + +4. Invoke again — the agent uses the optimized prompt without code changes: + ```bash + agentcore invoke --prompt "Who are you?" + ``` + +## Viewing History + +Results are saved in `.cli/recommendations/`. View past runs via the TUI: + +```bash +agentcore +# Navigate to: Recommendations → History +``` + +## TUI Wizard + +Run `agentcore` → Run → Recommendation for a guided flow: + +1. Select recommendation type (system prompt or tool description) +2. Select agent +3. Select evaluator (system prompt only) +4. Choose input source (inline, file, or config bundle) +5. Choose trace source (CloudWatch or sessions) +6. Confirm and run + +The TUI shows real-time progress and displays the recommended changes when complete, with an option to apply config +bundle updates. diff --git a/e2e-tests/README.md b/e2e-tests/README.md new file mode 100644 index 000000000..cdaa0066d --- /dev/null +++ b/e2e-tests/README.md @@ -0,0 +1,124 @@ +# E2E Tests + +This directory contains end-to-end tests that verify the full user journey across the AWS boundary. They create, deploy, +invoke, and destroy real AWS resources. + +## What E2E Tests Cover + +E2E tests verify behaviors that require AWS to confirm they happened: + +- **Deployment** — `agentcore deploy` creates a real CloudFormation stack +- **`deployed-state.json`** — after deploy, `agentcore/.cli/deployed-state.json` contains the correct ARNs and IDs for + each deployed resource +- **Live AWS state** — `agentcore status` returns a real resource ARN and `deploymentState: 'deployed'` +- **Live agent behavior** — `agentcore invoke` succeeds against a running agent +- **Observability** — `agentcore logs` returns real CloudWatch entries, `agentcore traces list` returns real trace data +- **Direct control plane API calls** — `pause`, `resume`, and `promote` on AB tests return live execution state from AWS + +They do **not** verify config file mutations or CLI input validation. Those belong in `integ-tests/`. + +## Prerequisites + +- AWS credentials configured (`aws sts get-caller-identity` must succeed) +- `npm`, `git`, and `uv` on PATH +- Sufficient IAM permissions to create/delete CloudFormation stacks +- A dedicated test AWS account (recommended to avoid cost surprises) +- Model-specific API keys set as env vars for non-Bedrock providers (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, + `GOOGLE_API_KEY`) + +## Running + +```bash +# Run all e2e tests (requires AWS credentials) +npm run test:e2e + +# Run a specific file +npx vitest run e2e-tests/strands-bedrock.test.ts +``` + +E2E tests are not run automatically on every PR. They run on a schedule and before releases. + +## Writing E2E Tests + +Most framework/model combination tests are a single call to `createE2ESuite()`: + +```typescript +import { createE2ESuite } from './e2e-helper.js'; + +createE2ESuite({ + framework: 'Strands', + modelProvider: 'Bedrock', +}); +``` + +`createE2ESuite()` generates the full lifecycle suite: `create → deploy → invoke → status → logs → traces → destroy`. + +For feature-specific lifecycle tests (AB tests, evals, config bundles), write the suite directly using helpers from +`e2e-helper.ts`: + +```typescript +import { baseCanRun, hasAws, runAgentCoreCLI, teardownE2EProject, writeAwsTargets } from './e2e-helper.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const canRun = baseCanRun && hasAws; + +describe.sequential('e2e: my feature lifecycle', () => { + let projectPath: string; + const agentName = `E2eMyFeat${String(Date.now()).slice(-8)}`; + + beforeAll(async () => { + if (!canRun) return; + // create project, write AWS targets + await writeAwsTargets(projectPath); + }, 300000); + + // Always destroy AWS resources — never skip this + afterAll(async () => { + if (projectPath && hasAws) { + await teardownE2EProject(projectPath, agentName, 'Bedrock'); + } + }, 600000); + + it.skipIf(!canRun)( + 'deploys to AWS successfully', + async () => { + const result = await runAgentCoreCLI(['deploy', '--yes', '--json'], projectPath); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).success).toBe(true); + }, + 600000 + ); +}); +``` + +### Key patterns + +| Pattern | Why | +| ----------------------------------------- | --------------------------------------------------------------------- | +| `describe.sequential` | Tests depend on each other — deploy must succeed before invoke | +| `it.skipIf(!canRun)` | Gracefully skips when credentials or prerequisites are missing | +| `afterAll(() => teardownE2EProject(...))` | Always destroy AWS resources to avoid cost and leakage | +| `retry(fn, 3, 15000)` | AWS operations are eventually consistent — retries handle cold starts | +| `hasAwsCredentials()` | Gate the entire suite — skip all if no credentials | +| Long timeouts (600000ms) | CloudFormation deploys take minutes, not seconds | + +### File naming + +Framework/model combination tests: `{framework}-{model}.test.ts` + +- `strands-bedrock.test.ts` +- `langgraph-openai.test.ts` + +Feature lifecycle tests: describe what the test exercises end-to-end + +- `ab-test-target-based.test.ts` +- `dev-lifecycle.test.ts` +- `evals-lifecycle.test.ts` + +## Important Notes + +- E2E tests create real AWS resources and **will incur costs** +- Always include `teardownE2EProject()` in `afterAll` — never skip cleanup +- Use unique agent names (timestamp suffix) to avoid conflicts with parallel runs +- Stale credential providers older than 30 minutes are cleaned up automatically in `beforeAll` via + `cleanupStaleCredentialProviders()` diff --git a/e2e-tests/ab-test-config-bundle.test.ts b/e2e-tests/ab-test-config-bundle.test.ts new file mode 100644 index 000000000..cec0a9cc0 --- /dev/null +++ b/e2e-tests/ab-test-config-bundle.test.ts @@ -0,0 +1,209 @@ +import { parseJsonOutput, retry } from '../src/test-utils/index.js'; +import { + baseCanRun, + hasAws, + installCdkTarball, + runAgentCoreCLI, + teardownE2EProject, + writeAwsTargets, +} from './e2e-helper.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const canRun = baseCanRun && hasAws; + +describe.sequential('e2e: config-bundle AB test lifecycle', () => { + let testDir: string; + let projectPath: string; + const agentName = `E2eCfgAB${String(Date.now()).slice(-8)}`; + const abTestName = 'ConfigBundleABTest'; + const evalName = 'BundleEvaluator'; + const onlineEvalName = 'BundleOnlineEval'; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-cfg-ab-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const result = await runAgentCoreCLI( + [ + 'create', + '--name', + agentName, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir + ); + expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0); + projectPath = (parseJsonOutput(result.stdout) as { projectPath: string }).projectPath; + + await writeAwsTargets(projectPath); + installCdkTarball(projectPath); + }, 300000); + + afterAll(async () => { + if (projectPath && hasAws) { + await teardownE2EProject(projectPath, agentName, 'Bedrock'); + } + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600000); + + const run = (args: string[]) => runAgentCoreCLI(args, projectPath); + + it.skipIf(!canRun)( + 'adds evaluator and online eval config', + async () => { + let result = await run([ + 'add', + 'evaluator', + '--name', + evalName, + '--level', + 'SESSION', + '--model', + 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + '--instructions', + 'Evaluate session quality. Context: {context}', + '--json', + ]); + expect(result.exitCode, `Add evaluator failed: ${result.stdout}`).toBe(0); + + result = await run([ + 'add', + 'online-eval', + '--name', + onlineEvalName, + '--runtime', + agentName, + '--evaluator', + evalName, + '--sampling-rate', + '100', + '--enable-on-create', + '--json', + ]); + expect(result.exitCode, `Add online-eval failed: ${result.stdout}`).toBe(0); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'deploys agent before AB test (needed for config bundles)', + async () => { + await retry( + async () => { + const result = await run(['deploy', '--yes', '--json']); + expect(result.exitCode, `Initial deploy failed`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 2, + 30000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'adds config-bundle AB test with 90/10 split', + async () => { + // Use placeholder bundle ARNs that satisfy the service format constraints. + // Real config bundles would be created separately; these test the AB test wiring. + const region = process.env.AWS_REGION ?? 'us-east-1'; + const account = process.env.AWS_ACCOUNT_ID ?? '000000000000'; + const controlBundle = `arn:aws:bedrock-agentcore:${region}:${account}:configuration-bundle/control-bundle-AbCdEfGhIj`; + const treatmentBundle = `arn:aws:bedrock-agentcore:${region}:${account}:configuration-bundle/treatment-bundle-AbCdEfGhIj`; + + const result = await run([ + 'add', + 'ab-test', + '--mode', + 'config-bundle', + '--name', + abTestName, + '--runtime', + agentName, + '--control-bundle', + controlBundle, + '--control-version', + '00000000-0000-0000-0000-000000000001', + '--treatment-bundle', + treatmentBundle, + '--treatment-version', + '00000000-0000-0000-0000-000000000002', + '--control-weight', + '90', + '--treatment-weight', + '10', + '--online-eval', + onlineEvalName, + '--json', + ]); + expect(result.exitCode, `Add AB test failed: ${result.stdout}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean; abTestName: string }; + expect(json.success).toBe(true); + expect(json.abTestName).toBe(abTestName); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'status shows AB test in config', + async () => { + const result = await run(['status', '--json']); + expect(result.exitCode, `Status failed: ${result.stderr}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as { + success: boolean; + resources: { resourceType: string; name: string; deploymentState: string }[]; + }; + expect(json.success).toBe(true); + + // Agent should be deployed + const agent = json.resources.find(r => r.resourceType === 'agent' && r.name === agentName); + expect(agent, `Agent "${agentName}" should appear in status`).toBeDefined(); + expect(agent!.deploymentState).toBe('deployed'); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'invokes the deployed agent', + async () => { + await retry( + async () => { + const result = await run(['invoke', '--prompt', 'Say hello', '--runtime', agentName, '--json']); + expect(result.exitCode, `Invoke failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 3, + 15000 + ); + }, + 180000 + ); + + it.skipIf(!canRun)( + 'removes config-bundle AB test', + async () => { + const result = await run(['remove', 'ab-test', '--name', abTestName, '--json']); + expect(result.exitCode, `Remove failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + }, + 60000 + ); +}); diff --git a/e2e-tests/ab-test-target-based.test.ts b/e2e-tests/ab-test-target-based.test.ts new file mode 100644 index 000000000..274ee447a --- /dev/null +++ b/e2e-tests/ab-test-target-based.test.ts @@ -0,0 +1,317 @@ +import { parseJsonOutput, retry } from '../src/test-utils/index.js'; +import { + baseCanRun, + hasAws, + installCdkTarball, + runAgentCoreCLI, + teardownE2EProject, + writeAwsTargets, +} from './e2e-helper.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const canRun = baseCanRun && hasAws; + +describe.sequential('e2e: target-based AB test lifecycle', () => { + let testDir: string; + let projectPath: string; + const agentName = `E2eTargAB${String(Date.now()).slice(-8)}`; + const abTestName = 'TargetABTest'; + const evalName = 'ABTestEvaluator'; + const controlEvalName = 'ControlEvalConfig'; + const treatmentEvalName = 'TreatmentEvalConfig'; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-target-ab-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const result = await runAgentCoreCLI( + [ + 'create', + '--name', + agentName, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir + ); + expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0); + projectPath = (parseJsonOutput(result.stdout) as { projectPath: string }).projectPath; + + await writeAwsTargets(projectPath); + installCdkTarball(projectPath); + }, 300000); + + afterAll(async () => { + if (projectPath && hasAws) { + await teardownE2EProject(projectPath, agentName, 'Bedrock'); + } + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600000); + + const run = (args: string[]) => runAgentCoreCLI(args, projectPath); + + it.skipIf(!canRun)( + 'adds runtime endpoints (prod v1, staging v1)', + async () => { + let result = await run([ + 'add', + 'runtime-endpoint', + '--runtime', + agentName, + '--endpoint', + 'prod', + '--version', + '1', + '--json', + ]); + expect(result.exitCode, `Add prod endpoint failed: ${result.stdout}`).toBe(0); + + result = await run([ + 'add', + 'runtime-endpoint', + '--runtime', + agentName, + '--endpoint', + 'staging', + '--version', + '1', + '--json', + ]); + expect(result.exitCode, `Add staging endpoint failed: ${result.stdout}`).toBe(0); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'adds evaluator and per-variant online eval configs', + async () => { + let result = await run([ + 'add', + 'evaluator', + '--name', + evalName, + '--level', + 'SESSION', + '--model', + 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + '--instructions', + 'Evaluate quality. Context: {context}', + '--json', + ]); + expect(result.exitCode, `Add evaluator failed: ${result.stdout}`).toBe(0); + + result = await run([ + 'add', + 'online-eval', + '--name', + controlEvalName, + '--runtime', + agentName, + '--evaluator', + evalName, + '--sampling-rate', + '100', + '--endpoint', + 'prod', + '--enable-on-create', + '--json', + ]); + expect(result.exitCode, `Add control online-eval failed: ${result.stdout}`).toBe(0); + + result = await run([ + 'add', + 'online-eval', + '--name', + treatmentEvalName, + '--runtime', + agentName, + '--evaluator', + evalName, + '--sampling-rate', + '100', + '--endpoint', + 'staging', + '--enable-on-create', + '--json', + ]); + expect(result.exitCode, `Add treatment online-eval failed: ${result.stdout}`).toBe(0); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'adds target-based AB test with 90/10 split', + async () => { + const result = await run([ + 'add', + 'ab-test', + '--mode', + 'target-based', + '--name', + abTestName, + '--runtime', + agentName, + '--gateway', + `${abTestName}-gw`, + '--control-endpoint', + 'prod', + '--treatment-endpoint', + 'staging', + '--control-weight', + '90', + '--treatment-weight', + '10', + '--control-online-eval', + controlEvalName, + '--treatment-online-eval', + treatmentEvalName, + '--enable', + '--json', + ]); + expect(result.exitCode, `Add AB test failed: ${result.stdout}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean; abTestName: string }; + expect(json.success).toBe(true); + expect(json.abTestName).toBe(abTestName); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'deploys project (creates gateway, targets, AB test, eval configs)', + async () => { + await retry( + async () => { + const result = await run(['deploy', '--yes', '--json']); + expect(result.exitCode, `Deploy failed (stderr: ${result.stderr})`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 2, + 30000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'AB test reaches RUNNING status after deploy', + async () => { + await retry( + async () => { + const result = await run(['ab-test', abTestName, '--json']); + expect(result.exitCode, `ab-test lookup failed: ${result.stdout} ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { executionStatus: string }; + expect(json.executionStatus, 'AB test should be RUNNING after deploy').toBe('RUNNING'); + }, + 12, + 15000 + ); + }, + 300000 + ); + + it.skipIf(!canRun)( + 'status shows all resources deployed', + async () => { + await retry( + async () => { + const result = await run(['status', '--json']); + expect(result.exitCode, `Status failed: ${result.stderr}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as { + success: boolean; + resources: { resourceType: string; name: string; deploymentState: string; invocationUrl?: string }[]; + }; + expect(json.success).toBe(true); + + // Agent should be deployed + const agent = json.resources.find(r => r.resourceType === 'agent' && r.name === agentName); + expect(agent, `Agent "${agentName}" should appear in status`).toBeDefined(); + expect(agent!.deploymentState).toBe('deployed'); + + // AB test should be deployed (HTTP gateways are not surfaced as top-level status resources) + const abTest = json.resources.find(r => r.resourceType === 'ab-test' && r.name === abTestName); + expect(abTest, `AB test "${abTestName}" should appear in status`).toBeDefined(); + expect(abTest!.deploymentState).toBe('deployed'); + // invocationUrl proves the HTTP gateway was deployed and wired up correctly + expect(abTest!.invocationUrl, 'AB test should have a gateway invocation URL').toBeTruthy(); + }, + 3, + 15000 + ); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'pauses AB test', + async () => { + await retry( + async () => { + const result = await run(['pause', 'ab-test', abTestName, '--json']); + expect(result.exitCode, `Pause failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json).toHaveProperty('executionStatus', 'PAUSED'); + }, + 3, + 10000 + ); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'resumes AB test', + async () => { + await retry( + async () => { + const result = await run(['resume', 'ab-test', abTestName, '--json']); + expect(result.exitCode, `Resume failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json).toHaveProperty('executionStatus', 'RUNNING'); + }, + 3, + 10000 + ); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'promotes AB test (updates agentcore.json)', + async () => { + const result = await run(['promote', 'ab-test', abTestName, '--json']); + expect(result.exitCode, `Promote failed: ${result.stdout} ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json).toHaveProperty('promoted', true); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'removes AB test from config', + async () => { + const result = await run(['remove', 'ab-test', '--name', abTestName, '--delete-gateway', '--json']); + expect(result.exitCode, `Remove failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + }, + 60000 + ); +}); diff --git a/e2e-tests/archive-lifecycle.test.ts b/e2e-tests/archive-lifecycle.test.ts new file mode 100644 index 000000000..dbe0a053e --- /dev/null +++ b/e2e-tests/archive-lifecycle.test.ts @@ -0,0 +1,333 @@ +/** + * E2E tests for the archive command. + * + * Flow: create project → deploy → invoke → run batch-eval → run recommendation → + * archive batch-eval (verify service delete + local .cli cleared) → + * archive recommendation (verify service delete + local .cli cleared) + * + * Prerequisites: + * - AWS credentials + * - npm, git, uv installed + */ +import { parseJsonOutput, retry } from '../src/test-utils/index.js'; +import { + baseCanRun, + hasAws, + installCdkTarball, + runAgentCoreCLI, + teardownE2EProject, + writeAwsTargets, +} from './e2e-helper.js'; +import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const canRun = baseCanRun && hasAws; + +describe.sequential('e2e: archive command lifecycle', () => { + let testDir: string; + let projectPath: string; + const agentName = `E2eArch${String(Date.now()).slice(-8)}`; + + // IDs captured from run steps and used in archive steps + let batchEvaluationId: string; + let recommendationId: string; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-archive-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const result = await runAgentCoreCLI( + [ + 'create', + '--name', + agentName, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir + ); + expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0); + projectPath = (parseJsonOutput(result.stdout) as { projectPath: string }).projectPath; + + await writeAwsTargets(projectPath); + installCdkTarball(projectPath); + }, 300000); + + afterAll(async () => { + if (projectPath && hasAws) { + await teardownE2EProject(projectPath, agentName, 'Bedrock'); + } + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600000); + + const run = (args: string[]) => runAgentCoreCLI(args, projectPath); + + // ════════════════════════════════════════════════════════════════════════ + // Setup — deploy and generate traces + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'deploys the agent', + async () => { + const result = await run(['deploy', '--yes', '--json']); + if (result.exitCode !== 0) { + console.log('Deploy stdout:', result.stdout); + console.log('Deploy stderr:', result.stderr); + } + expect(result.exitCode, 'Deploy failed').toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'invokes the deployed agent to generate traces', + async () => { + await retry( + async () => { + const result = await run(['invoke', '--prompt', 'Say hello', '--runtime', agentName, '--json']); + expect(result.exitCode, `Invoke failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 3, + 15000 + ); + }, + 180000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Batch evaluation — run and capture ID + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'runs batch evaluation and captures the ID', + async () => { + await retry( + async () => { + const result = await run([ + 'run', + 'batch-evaluation', + '--runtime', + agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--lookback-days', + '1', + '--json', + ]); + expect(result.exitCode, `batch-evaluation failed (stdout: ${result.stdout}, stderr: ${result.stderr})`).toBe( + 0 + ); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json.batchEvaluationId).toBeTruthy(); + expect(json.status).not.toBe('FAILED'); + batchEvaluationId = json.batchEvaluationId as string; + }, + 6, + 15000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'local .cli/batch-eval-results contains the run record', + () => { + expect(batchEvaluationId, 'batchEvaluationId should have been captured').toBeTruthy(); + const filePath = join(projectPath, 'agentcore', '.cli', 'batch-eval-results', `${batchEvaluationId}.json`); + expect(existsSync(filePath), `Expected local record at ${filePath}`).toBe(true); + }, + 30000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Recommendation — run and capture ID + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'runs a recommendation and captures the ID', + async () => { + await retry( + async () => { + const result = await run([ + 'run', + 'recommendation', + '--runtime', + agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--inline', + 'You are a helpful assistant for testing.', + '--lookback', + '1', + '--json', + ]); + expect(result.exitCode, `recommendation failed (stdout: ${result.stdout}, stderr: ${result.stderr})`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json.recommendationId).toBeTruthy(); + recommendationId = json.recommendationId as string; + }, + 6, + 30000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'local .cli/recommendations contains the run record', + () => { + expect(recommendationId, 'recommendationId should have been captured').toBeTruthy(); + const filePath = join(projectPath, 'agentcore', '.cli', 'recommendations', `${recommendationId}.json`); + expect(existsSync(filePath), `Expected local record at ${filePath}`).toBe(true); + }, + 30000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Archive batch evaluation + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'archive batch-evaluation fails without --id flag', + async () => { + const result = await run(['archive', 'batch-evaluation']); + expect(result.exitCode).not.toBe(0); + }, + 30000 + ); + + it.skipIf(!canRun)( + 'archives the batch evaluation with --json flag', + async () => { + expect(batchEvaluationId, 'batchEvaluationId must have been captured').toBeTruthy(); + + const result = await run(['archive', 'batch-evaluation', '--id', batchEvaluationId, '--json']); + expect(result.exitCode, `archive batch-evaluation failed: ${result.stderr}\n${result.stdout}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json.batchEvaluationId).toBe(batchEvaluationId); + expect(json).toHaveProperty('localCliHistoryDeleted', true); + expect(json.localDeleteWarning).toBeUndefined(); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'local .cli/batch-eval-results no longer contains the archived record', + () => { + const filePath = join(projectPath, 'agentcore', '.cli', 'batch-eval-results', `${batchEvaluationId}.json`); + expect(existsSync(filePath), `Local record should have been deleted from ${filePath}`).toBe(false); + }, + 30000 + ); + + it.skipIf(!canRun)( + 'evals history does not surface the archived batch evaluation ID', + async () => { + // evals history lists on-demand (run eval) records — batch evals are stored separately. + // Verify: the command succeeds and contains no entry matching our batch evaluation ID. + const result = await run(['evals', 'history', '--json']); + expect(result.exitCode, `evals history failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { runs?: { agent: string }[] }; + const output = JSON.stringify(json.runs ?? []); + expect(output).not.toContain(batchEvaluationId); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'archiving the same batch evaluation again returns success false (already deleted)', + async () => { + const result = await run(['archive', 'batch-evaluation', '--id', batchEvaluationId, '--json']); + // Service should return an error (resource not found / already deleted) + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toBeTruthy(); + }, + 120000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Archive recommendation + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'archive recommendation fails without --id flag', + async () => { + const result = await run(['archive', 'recommendation']); + expect(result.exitCode).not.toBe(0); + }, + 30000 + ); + + it.skipIf(!canRun)( + 'archives the recommendation with --json flag', + async () => { + expect(recommendationId, 'recommendationId must have been captured').toBeTruthy(); + + const result = await run(['archive', 'recommendation', '--id', recommendationId, '--json']); + expect(result.exitCode, `archive recommendation failed: ${result.stderr}\n${result.stdout}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json.recommendationId).toBe(recommendationId); + expect(json).toHaveProperty('localCliHistoryDeleted', true); + expect(json.localDeleteWarning).toBeUndefined(); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'local .cli/recommendations no longer contains the archived record', + () => { + const filePath = join(projectPath, 'agentcore', '.cli', 'recommendations', `${recommendationId}.json`); + expect(existsSync(filePath), `Local record should have been deleted from ${filePath}`).toBe(false); + }, + 30000 + ); + + it.skipIf(!canRun)( + 'recommendations history no longer includes the archived entry', + async () => { + const result = await run(['recommendations', 'history', '--json']); + expect(result.exitCode, `recommendations history failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { recommendations: { recommendationId: string }[] }; + const ids = (json.recommendations ?? []).map(r => r.recommendationId); + expect(ids).not.toContain(recommendationId); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'archiving the same recommendation again returns success false (already deleted)', + async () => { + const result = await run(['archive', 'recommendation', '--id', recommendationId, '--json']); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toBeTruthy(); + }, + 120000 + ); +}); diff --git a/e2e-tests/byo-custom-jwt.test.ts b/e2e-tests/byo-custom-jwt.test.ts index b7391a522..64e534e20 100644 --- a/e2e-tests/byo-custom-jwt.test.ts +++ b/e2e-tests/byo-custom-jwt.test.ts @@ -48,7 +48,7 @@ const region = process.env.AWS_REGION ?? 'us-east-1'; * Run the local CLI build without skipping install (needed for deploy). */ function runLocalCLI(args: string[], cwd: string): Promise { - return runCLI(args, cwd, /* skipInstall */ false); + return runCLI(args, cwd, { skipInstall: false }); } describe.sequential('e2e: BYO agent with CUSTOM_JWT auth', () => { diff --git a/e2e-tests/config-bundle-eval-rec.test.ts b/e2e-tests/config-bundle-eval-rec.test.ts new file mode 100644 index 000000000..01e3287bf --- /dev/null +++ b/e2e-tests/config-bundle-eval-rec.test.ts @@ -0,0 +1,638 @@ +/** + * E2E tests for Config Bundles, Batch Evaluation, and Recommendations. + * + * Flow: create project → add config bundle → add evaluator → deploy → + * invoke → test config-bundle CLI → run batch-evaluation → run recommendation + * + * Prerequisites: + * - AWS credentials + * - npm, git, uv installed + */ +import { parseJsonOutput, retry } from '../src/test-utils/index.js'; +import { + baseCanRun, + hasAws, + installCdkTarball, + runAgentCoreCLI, + teardownE2EProject, + writeAwsTargets, +} from './e2e-helper.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const canRun = baseCanRun && hasAws; + +describe.sequential('e2e: config bundles, batch evaluation, and recommendations', () => { + let testDir: string; + let projectPath: string; + const agentName = `E2eCbEr${String(Date.now()).slice(-8)}`; + const bundleName = 'E2eTestBundle'; + const evalName = 'E2eCustomEval'; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-cb-eval-rec-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + // Create project with agent + const result = await runAgentCoreCLI( + [ + 'create', + '--name', + agentName, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir + ); + expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0); + projectPath = (parseJsonOutput(result.stdout) as { projectPath: string }).projectPath; + + await writeAwsTargets(projectPath); + installCdkTarball(projectPath); + }, 300000); + + afterAll(async () => { + if (projectPath && hasAws) { + await teardownE2EProject(projectPath, agentName, 'Bedrock'); + } + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600000); + + const run = (args: string[]) => runAgentCoreCLI(args, projectPath); + + // ════════════════════════════════════════════════════════════════════════ + // Config Bundle — add to project + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'adds a config bundle to the project', + async () => { + const components = JSON.stringify({ + [`{{runtime:${agentName}}}`]: { + configuration: { + systemPrompt: 'You are a helpful e2e test assistant.', + temperature: 0.7, + }, + }, + }); + + const result = await run([ + 'add', + 'config-bundle', + '--name', + bundleName, + '--description', + 'E2E test config bundle', + '--components', + components, + '--branch', + 'mainline', + '--commit-message', + 'Initial e2e bundle', + '--json', + ]); + + expect(result.exitCode, `Add config-bundle failed: ${result.stdout}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(true); + expect(json.bundleName).toBe(bundleName); + }, + 60000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Evaluator — add to project + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'adds a custom evaluator to the project', + async () => { + const result = await run([ + 'add', + 'evaluator', + '--name', + evalName, + '--level', + 'SESSION', + '--model', + 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + '--instructions', + 'Evaluate the overall quality of this session. Context: {context}', + '--json', + ]); + + expect(result.exitCode, `Add evaluator failed: ${result.stdout}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(true); + expect(json.evaluatorName).toBe(evalName); + }, + 60000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Deploy + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'deploys the project with config bundle and evaluator', + async () => { + const result = await run(['deploy', '--yes', '--json']); + + if (result.exitCode !== 0) { + console.log('Deploy stdout:', result.stdout); + console.log('Deploy stderr:', result.stderr); + } + + expect(result.exitCode, 'Deploy failed').toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 600000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Invoke — generate traces for evaluation + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'invokes the deployed agent to generate traces', + async () => { + await retry( + async () => { + const result = await run([ + 'invoke', + '--prompt', + 'What is 3 + 5? Use the add_numbers tool.', + '--runtime', + agentName, + '--json', + ]); + expect(result.exitCode, `Invoke failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 3, + 15000 + ); + }, + 180000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Status — verify config bundle and evaluator deployed + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'status shows deployed config bundle and evaluator', + async () => { + const result = await run(['status', '--json']); + + expect(result.exitCode, `Status failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { + success: boolean; + resources: { resourceType: string; name: string; deploymentState: string }[]; + }; + expect(json.success).toBe(true); + + const bundle = json.resources.find(r => r.resourceType === 'config-bundle' && r.name === bundleName); + expect(bundle, `Config bundle "${bundleName}" should appear in status`).toBeDefined(); + + const evaluator = json.resources.find(r => r.resourceType === 'evaluator' && r.name === evalName); + expect(evaluator, `Evaluator "${evalName}" should appear in status`).toBeDefined(); + }, + 120000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Config Bundle — versions and diff via CLI + // ════════════════════════════════════════════════════════════════════════ + + let initialVersionId: string; + + it.skipIf(!canRun)( + 'config-bundle versions lists the deployed version', + async () => { + const result = await run(['config-bundle', 'versions', '--bundle', bundleName, '--json']); + + expect(result.exitCode, `cb versions failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { + versions: { versionId: string; lineageMetadata?: { branchName?: string; commitMessage?: string } }[]; + bundleName: string; + }; + + expect(json.bundleName).toBe(bundleName); + expect(json.versions.length).toBeGreaterThanOrEqual(1); + initialVersionId = json.versions[0]!.versionId; + expect(initialVersionId).toBeTruthy(); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'config-bundle versions supports --branch filter', + async () => { + const result = await run(['config-bundle', 'versions', '--bundle', bundleName, '--branch', 'mainline', '--json']); + + expect(result.exitCode, `cb versions --branch failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { + versions: { versionId: string; lineageMetadata?: { branchName?: string } }[]; + }; + + for (const v of json.versions) { + expect(v.lineageMetadata?.branchName).toBe('mainline'); + } + }, + 120000 + ); + + it.skipIf(!canRun)( + 'updates config bundle by redeploying with changed components', + async () => { + // Update the config bundle in agentcore.json with new component values + const components = JSON.stringify({ + [`{{runtime:${agentName}}}`]: { + configuration: { + systemPrompt: 'You are an UPDATED e2e test assistant.', + temperature: 0.9, + maxTokens: 2048, + }, + }, + }); + + // Remove old bundle, add new one with same name but different components + let result = await run(['remove', 'config-bundle', '--name', bundleName, '--json']); + expect(result.exitCode, `Remove config-bundle failed: ${result.stdout}`).toBe(0); + + result = await run([ + 'add', + 'config-bundle', + '--name', + bundleName, + '--description', + 'E2E test config bundle - updated', + '--components', + components, + '--branch', + 'mainline', + '--commit-message', + 'Update system prompt and add maxTokens', + '--json', + ]); + expect(result.exitCode, `Re-add config-bundle failed: ${result.stdout}`).toBe(0); + + // Redeploy to push the updated bundle + result = await run(['deploy', '--yes', '--json']); + expect(result.exitCode, `Redeploy failed: ${result.stdout}`).toBe(0); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'config-bundle versions shows both versions after update', + async () => { + const result = await run(['config-bundle', 'versions', '--bundle', bundleName, '--json']); + + expect(result.exitCode, `cb versions failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { + versions: { versionId: string }[]; + }; + + expect(json.versions.length).toBeGreaterThanOrEqual(2); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'config-bundle diff shows changes between versions', + async () => { + // Get the latest two versions + const versionsResult = await run(['config-bundle', 'versions', '--bundle', bundleName, '--json']); + const versionsJson = parseJsonOutput(versionsResult.stdout) as { + versions: { versionId: string }[]; + }; + + expect(versionsJson.versions.length).toBeGreaterThanOrEqual(2); + const newestVersion = versionsJson.versions[0]!.versionId; + const oldestVersion = versionsJson.versions[versionsJson.versions.length - 1]!.versionId; + + const result = await run([ + 'config-bundle', + 'diff', + '--bundle', + bundleName, + '--from', + oldestVersion, + '--to', + newestVersion, + '--json', + ]); + + expect(result.exitCode, `cb diff failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('fromVersion'); + expect(json).toHaveProperty('toVersion'); + expect(json.diffs).toBeInstanceOf(Array); + expect((json.diffs as unknown[]).length).toBeGreaterThan(0); + }, + 120000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Batch Evaluation — run through CLI + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'runs batch evaluation with Builtin evaluator via CLI', + async () => { + await retry( + async () => { + const result = await run([ + 'run', + 'batch-evaluation', + '--runtime', + agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--lookback-days', + '1', + '--json', + ]); + + expect(result.exitCode, `batch-evaluation failed (stdout: ${result.stdout}, stderr: ${result.stderr})`).toBe( + 0 + ); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json).toHaveProperty('batchEvaluationId'); + expect(json.status).toBeDefined(); + expect(json.status).not.toBe('FAILED'); + }, + 6, + 15000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'runs batch evaluation with ground truth file', + async () => { + // Invoke to get a real session ID for ground truth + const invokeResult = await run(['invoke', '--prompt', 'What is 2+2?', '--runtime', agentName, '--json']); + expect(invokeResult.exitCode).toBe(0); + const invokeJson = parseJsonOutput(invokeResult.stdout) as { sessionId: string }; + expect(invokeJson.sessionId).toBeTruthy(); + + // Create ground truth file using the real session ID + const gtData = [ + { + sessionId: invokeJson.sessionId, + groundTruth: { + inline: { + assertions: [{ text: 'Agent should provide a numerical answer' }], + }, + }, + }, + ]; + const gtPath = join(projectPath, 'ground-truth.json'); + await writeFile(gtPath, JSON.stringify(gtData)); + + await retry( + async () => { + const result = await run([ + 'run', + 'batch-evaluation', + '--runtime', + agentName, + '--evaluator', + 'Builtin.Correctness', + '--ground-truth', + gtPath, + '--lookback-days', + '1', + '--json', + ]); + + expect(result.exitCode, `batch-evaluation with GT failed: ${result.stdout}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + }, + 6, + 15000 + ); + }, + 600000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // On-demand Eval — run eval via CLI (existing pattern) + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'runs on-demand eval with Builtin evaluator via CLI', + async () => { + // Retries needed: traces from invoke take time to propagate to CloudWatch + await retry( + async () => { + const result = await run([ + 'run', + 'eval', + '--runtime', + agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--days', + '1', + '--json', + ]); + + expect(result.exitCode, `run eval failed (stdout: ${result.stdout}, stderr: ${result.stderr})`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json).toHaveProperty('run'); + expect(json).toHaveProperty('filePath'); + }, + 10, + 15000 + ); + }, + 300000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Recommendation — run through CLI + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'runs system prompt recommendation with inline content via CLI', + async () => { + await retry( + async () => { + const result = await run([ + 'run', + 'recommendation', + '--runtime', + agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--inline', + 'You are a helpful assistant for testing.', + '--lookback', + '1', + '--json', + ]); + + expect(result.exitCode, `recommendation failed (stdout: ${result.stdout}, stderr: ${result.stderr})`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json).toHaveProperty('recommendationId'); + expect(json.result).toBeDefined(); + expect(json.result).not.toBe(''); + expect(json.result).not.toBeNull(); + }, + 6, + 30000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'runs system prompt recommendation with prompt file via CLI', + async () => { + const promptFile = join(projectPath, 'system-prompt.txt'); + await writeFile(promptFile, 'You are a helpful customer support assistant. Answer politely.'); + + await retry( + async () => { + const result = await run([ + 'run', + 'recommendation', + '--runtime', + agentName, + '--evaluator', + 'Builtin.Helpfulness', + '--prompt-file', + promptFile, + '--lookback', + '1', + '--json', + ]); + + expect(result.exitCode, `recommendation from file failed: ${result.stdout}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json).toHaveProperty('recommendationId'); + }, + 6, + 30000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'runs tool description recommendation via CLI', + async () => { + await retry( + async () => { + const result = await run([ + 'run', + 'recommendation', + '--type', + 'tool-description', + '--runtime', + agentName, + '--tools', + 'add_numbers:Adds two numbers together', + '--lookback', + '1', + '--json', + ]); + + expect(result.exitCode, `tool-desc recommendation failed: ${result.stdout}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json).toHaveProperty('recommendationId'); + }, + 6, + 30000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'runs recommendation with config bundle source via CLI', + async () => { + // Get the latest version ID for the bundle + const versionsResult = await run(['config-bundle', 'versions', '--bundle', bundleName, '--json']); + const versionsJson = parseJsonOutput(versionsResult.stdout) as { + versions: { versionId: string }[]; + }; + const latestVersion = versionsJson.versions[0]!.versionId; + + await retry( + async () => { + const result = await run([ + 'run', + 'recommendation', + '--runtime', + agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--bundle-name', + bundleName, + '--bundle-version', + latestVersion, + '--system-prompt-json-path', + 'systemPrompt', + '--lookback', + '1', + '--json', + ]); + + expect(result.exitCode, `bundle recommendation failed: ${result.stdout}`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json).toHaveProperty('recommendationId'); + }, + 6, + 30000 + ); + }, + 600000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Cleanup — remove config bundle from project + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'removes config bundle from project and redeploys (reconciliation deletes it)', + async () => { + let result = await run(['remove', 'config-bundle', '--name', bundleName, '--json']); + expect(result.exitCode, `Remove config-bundle failed: ${result.stdout}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(true); + + // Redeploy triggers reconciliation (orphaned bundle deleted server-side) + result = await run(['deploy', '--yes', '--json']); + expect(result.exitCode, `Final deploy failed: ${result.stdout}`).toBe(0); + }, + 600000 + ); +}); diff --git a/e2e-tests/e2e-helper.ts b/e2e-tests/e2e-helper.ts index 5e3d30b39..e0f6a51cc 100644 --- a/e2e-tests/e2e-helper.ts +++ b/e2e-tests/e2e-helper.ts @@ -14,7 +14,7 @@ import { } from '@aws-sdk/client-bedrock-agentcore-control'; import { execSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -377,3 +377,44 @@ export async function teardownE2EProject(projectPath: string, agentName: string, await deleteCredentialProvider(client, `${agentName}${modelProvider}`); } } + +export async function dumpImportDebugInfo( + label: string, + result: RunResult, + projectPath: string, + stackName: string, + region: string +): Promise { + console.log(`Import ${label} stdout:`, result.stdout); + console.log(`Import ${label} stderr:`, result.stderr); + + const logMatch = /Log: (.+)/.exec(result.stderr); + if (logMatch) { + try { + const logContents = await readFile(join(projectPath, logMatch[1]!), 'utf-8'); + console.log(`Import ${label} log:\n`, logContents); + } catch { + /* log file may not exist */ + } + } + + const cfnEvents = await spawnAndCollect( + 'aws', + [ + 'cloudformation', + 'describe-stack-events', + '--stack-name', + stackName, + '--query', + 'StackEvents[?ResourceStatus==`IMPORT_FAILED` || ResourceStatus==`IMPORT_ROLLBACK_IN_PROGRESS`]', + '--output', + 'json', + '--region', + region, + ], + projectPath + ); + if (cfnEvents.exitCode === 0 && cfnEvents.stdout.trim() !== '[]') { + console.log(`CloudFormation failed events for ${label}:`, cfnEvents.stdout); + } +} diff --git a/e2e-tests/fixtures/import/common.py b/e2e-tests/fixtures/import/common.py index b49ffb277..3573ed519 100644 --- a/e2e-tests/fixtures/import/common.py +++ b/e2e-tests/fixtures/import/common.py @@ -2,15 +2,20 @@ import json import os import time +import uuid import zipfile import tempfile import boto3 REGION = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1" +RESOURCE_SUFFIX = os.environ.get("RESOURCE_SUFFIX", "") +# Unique suffix for resource names — avoids collisions across parallel CI shards. +NAME_SUFFIX = RESOURCE_SUFFIX or uuid.uuid4().hex[:12] SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) APP_DIR = os.path.join(SCRIPT_DIR, "app") -RESOURCES_FILE = os.path.join(SCRIPT_DIR, "bugbash-resources.json") +_resources_name = f"bugbash-resources-{RESOURCE_SUFFIX}.json" if RESOURCE_SUFFIX else "bugbash-resources.json" +RESOURCES_FILE = os.path.join(SCRIPT_DIR, _resources_name) INLINE_POLICY_NAME = "bugbash-agentcore-permissions" @@ -35,6 +40,8 @@ def upload_code(prefix="bugbash"): """Zip APP_DIR and upload to S3. Returns (bucket, s3_key).""" bucket_name = get_code_bucket() s3 = boto3.client("s3", region_name=REGION) + if RESOURCE_SUFFIX: + prefix = f"{prefix}-{RESOURCE_SUFFIX}" # Create zip of app directory with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: @@ -256,3 +263,48 @@ def tag_resource(client, arn, tags): """Tag a resource via the control plane API.""" print(f"Tagging resource with {tags}...") client.tag_resource(resourceArn=arn, tags=tags) + + +def wait_for_gateway(client, gateway_id, timeout=120): + """Wait for a gateway to reach READY status.""" + print(f"Waiting for gateway {gateway_id} to become READY...") + start = time.time() + while time.time() - start < timeout: + resp = client.get_gateway(gatewayIdentifier=gateway_id) + status = resp.get("status", "UNKNOWN") + if status == "READY": + print(f"Gateway {gateway_id} is READY") + return True + if status in ("CREATE_FAILED", "FAILED"): + reason = resp.get("statusReasons", [{}]) + print(f"ERROR: Gateway {gateway_id} status: {status} — {reason}") + return False + elapsed = int(time.time() - start) + print(f" Status: {status} ({elapsed}s elapsed)") + time.sleep(5) + print(f"WARNING: Gateway did not reach READY after {timeout}s") + return False + + +def wait_for_gateway_target(client, gateway_id, target_id, timeout=120): + """Wait for a gateway target to reach READY status.""" + print(f"Waiting for target {target_id} to become READY...") + start = time.time() + while time.time() - start < timeout: + resp = client.get_gateway_target( + gatewayIdentifier=gateway_id, + targetId=target_id, + ) + status = resp.get("status", "UNKNOWN") + if status == "READY": + print(f"Target {target_id} is READY") + return True + if status in ("CREATE_FAILED", "FAILED"): + reason = resp.get("statusReasons", [{}]) + print(f"ERROR: Target {target_id} status: {status} — {reason}") + return False + elapsed = int(time.time() - start) + print(f" Status: {status} ({elapsed}s elapsed)") + time.sleep(5) + print(f"WARNING: Target did not reach READY after {timeout}s") + return False diff --git a/e2e-tests/fixtures/import/setup_evaluator.py b/e2e-tests/fixtures/import/setup_evaluator.py index d49787d0e..e4573da45 100644 --- a/e2e-tests/fixtures/import/setup_evaluator.py +++ b/e2e-tests/fixtures/import/setup_evaluator.py @@ -8,10 +8,10 @@ import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import time from common import ( get_control_client, save_resource, tag_resource, wait_for_evaluator, print_import_command, + NAME_SUFFIX, ) DEFAULT_EVALUATOR_MODEL = os.environ.get("DEFAULT_EVALUATOR_MODEL", "us.anthropic.claude-sonnet-4-5-20250929-v1:0") @@ -19,8 +19,7 @@ def main(): client = get_control_client() - ts = int(time.time()) - evaluator_name = f"bugbash_eval_{ts}" + evaluator_name = f"bugbash_eval_{NAME_SUFFIX}" print(f"Creating evaluator: {evaluator_name}") resp = client.create_evaluator( diff --git a/e2e-tests/fixtures/import/setup_gateway.py b/e2e-tests/fixtures/import/setup_gateway.py new file mode 100644 index 000000000..a846617aa --- /dev/null +++ b/e2e-tests/fixtures/import/setup_gateway.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Setup: Gateway with MCP server target + tags. + +Tests: gateway import, target mapping, authorizerType, enableSemanticSearch, + exceptionLevel, tags, deployed state nesting under mcp.gateways. + +Creates: + 1. A gateway with NONE authorizer + semantic search enabled + 2. An MCP Server target pointing to a public test endpoint +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from common import ( + REGION, get_control_client, ensure_role, save_resource, + tag_resource, wait_for_gateway, wait_for_gateway_target, + NAME_SUFFIX, +) + + +def main(): + role_arn = ensure_role() + client = get_control_client() + gateway_name = f"bugbashGw{NAME_SUFFIX}" + + # ------------------------------------------------------------------ + # 1. Create gateway + # ------------------------------------------------------------------ + print(f"Creating gateway: {gateway_name}") + resp = client.create_gateway( + name=gateway_name, + description="Bugbash gateway for import testing", + roleArn=role_arn, + protocolType="MCP", + protocolConfiguration={ + "mcp": { + "supportedVersions": ["2025-03-26"], + "searchType": "SEMANTIC", + }, + }, + authorizerType="NONE", + exceptionLevel="DEBUG", + ) + + gateway_id = resp["gatewayId"] + gateway_arn = resp["gatewayArn"] + print(f"Gateway ID: {gateway_id}") + print(f"Gateway ARN: {gateway_arn}") + + tag_resource(client, gateway_arn, { + "env": "bugbash", + "team": "agentcore-cli", + }) + + save_resource("gateway", gateway_arn, gateway_id) + + if not wait_for_gateway(client, gateway_id): + print("Gateway creation failed. Aborting target creation.") + sys.exit(1) + + # ------------------------------------------------------------------ + # 2. Create MCP Server target + # ------------------------------------------------------------------ + target_name = "mcpTarget" + print(f"\nCreating MCP Server target: {target_name}") + target_resp = client.create_gateway_target( + gatewayIdentifier=gateway_id, + name=target_name, + targetConfiguration={ + "mcp": { + "mcpServer": { + "endpoint": "https://mcp.exa.ai/mcp", + }, + }, + }, + ) + + target_id = target_resp["targetId"] + print(f"Target ID: {target_id}") + + save_resource("gateway-target-mcp", gateway_arn, target_id) + wait_for_gateway_target(client, gateway_id, target_id) + + +if __name__ == "__main__": + main() diff --git a/e2e-tests/fixtures/import/setup_memory_full.py b/e2e-tests/fixtures/import/setup_memory_full.py index 5df196524..277179cfb 100644 --- a/e2e-tests/fixtures/import/setup_memory_full.py +++ b/e2e-tests/fixtures/import/setup_memory_full.py @@ -8,22 +8,22 @@ import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import time from common import ( ensure_role, get_control_client, wait_for_memory, save_resource, print_import_command, tag_resource, + NAME_SUFFIX, ) def main(): role_arn = ensure_role() client = get_control_client() - memory_name = f"bugbash_memory_{int(time.time())}" + memory_name = f"bugbash_memory_{NAME_SUFFIX}" print(f"Creating memory: {memory_name}") resp = client.create_memory( name=memory_name, - clientToken=f"bugbash-{int(time.time())}", + clientToken=f"bugbash-{NAME_SUFFIX}", eventExpiryDuration=30, memoryExecutionRoleArn=role_arn, memoryStrategies=[ diff --git a/e2e-tests/fixtures/import/setup_runtime_basic.py b/e2e-tests/fixtures/import/setup_runtime_basic.py index 65e1585a1..d29ddbd1c 100644 --- a/e2e-tests/fixtures/import/setup_runtime_basic.py +++ b/e2e-tests/fixtures/import/setup_runtime_basic.py @@ -7,20 +7,19 @@ import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import time from common import ( ensure_role, get_control_client, wait_for_runtime, save_resource, print_import_command, upload_code, + NAME_SUFFIX, ) def main(): role_arn = ensure_role() client = get_control_client() - ts = int(time.time()) - runtime_name = f"bugbash_basic_{ts}" + runtime_name = f"bugbash_basic_{NAME_SUFFIX}" - bucket, s3_key = upload_code(f"bugbash-basic-{ts}") + bucket, s3_key = upload_code(f"bugbash-basic-{NAME_SUFFIX}") print(f"Creating basic runtime: {runtime_name}") resp = client.create_agent_runtime( diff --git a/e2e-tests/import-gateway.test.ts b/e2e-tests/import-gateway.test.ts new file mode 100644 index 000000000..fd80ae967 --- /dev/null +++ b/e2e-tests/import-gateway.test.ts @@ -0,0 +1,196 @@ +import { + type RunResult, + hasAwsCredentials, + hasCommand, + parseJsonOutput, + prereqs, + spawnAndCollect, + stripAnsi, +} from '../src/test-utils/index.js'; +import { dumpImportDebugInfo, installCdkTarball, runAgentCoreCLI, writeAwsTargets } from './e2e-helper.js'; +import { execSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const hasAws = hasAwsCredentials(); +const hasPython = + hasCommand('python3') && + (() => { + try { + execSync('uv run --with boto3 python3 -c "import boto3"', { stdio: 'ignore' }); + return true; + } catch { + return false; + } + })(); +const canRun = prereqs.npm && prereqs.git && prereqs.uv && hasAws && hasPython; + +describe.sequential('e2e: import gateway', () => { + const region = process.env.AWS_REGION ?? 'us-east-1'; + const fixtureDir = join(__dirname, 'fixtures', 'import'); + const suffix = Date.now().toString().slice(-8); + const agentName = `E2eGw${suffix}`; + + let gatewayArn: string; + let projectPath: string; + let testDir: string; + + beforeAll(async () => { + if (!canRun) return; + + const result = await spawnAndCollect('uv', ['run', '--with', 'boto3', 'python3', 'setup_gateway.py'], fixtureDir, { + AWS_REGION: region, + RESOURCE_SUFFIX: suffix, + }); + if (result.exitCode !== 0) { + throw new Error( + `setup_gateway.py failed (exit ${result.exitCode}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + } + + const resourcesPath = join(fixtureDir, `bugbash-resources-${suffix}.json`); + const resources = JSON.parse(await readFile(resourcesPath, 'utf-8')) as Record; + gatewayArn = resources.gateway!.arn; + + testDir = join(tmpdir(), `agentcore-e2e-import-gw-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const createResult = await runAgentCoreCLI( + ['create', '--name', agentName, '--no-agent', '--defaults', '--skip-git', '--skip-python-setup', '--json'], + testDir + ); + expect(createResult.exitCode, `Create failed: ${createResult.stderr}`).toBe(0); + projectPath = (parseJsonOutput(createResult.stdout) as { projectPath: string }).projectPath; + + await writeAwsTargets(projectPath); + installCdkTarball(projectPath); + }, 600_000); + + afterAll(async () => { + if (projectPath && hasAws) { + await runAgentCoreCLI(['remove', 'all', '--json'], projectPath); + const deployResult = await runAgentCoreCLI(['deploy', '--yes', '--json'], projectPath); + if (deployResult.exitCode !== 0) { + console.warn('Teardown deploy failed:', deployResult.stderr); + } + } + + try { + await spawnAndCollect('uv', ['run', '--with', 'boto3', 'python3', 'cleanup_resources.py'], fixtureDir, { + AWS_REGION: region, + RESOURCE_SUFFIX: suffix, + }); + } catch { + /* ignore — resources may already be deleted by CFN teardown */ + } + + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600_000); + + const run = (args: string[]): Promise => runAgentCoreCLI(args, projectPath); + const stackName = `AgentCore-${agentName}-default`; + + // ── Import test ─────────────────────────────────────────────────── + + it.skipIf(!canRun)( + 'imports a gateway by ARN', + async () => { + const result = await run(['import', 'gateway', '--arn', gatewayArn]); + + if (result.exitCode !== 0) { + await dumpImportDebugInfo('gateway', result, projectPath, stackName, region); + } + + expect(result.exitCode, `Import gateway failed: ${result.stderr}`).toBe(0); + expect(stripAnsi(result.stdout).toLowerCase()).toContain('imported successfully'); + }, + 600_000 + ); + + // ── Verification tests ──────────────────────────────────────────── + + it.skipIf(!canRun)( + 'status shows imported gateway as deployed', + async () => { + const result = await run(['status', '--json']); + + expect(result.exitCode, `Status failed: ${result.stderr}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as { + success: boolean; + resources: { resourceType: string; name: string; deploymentState: string }[]; + }; + expect(json.success).toBe(true); + + const gateway = json.resources.find(r => r.resourceType === 'gateway'); + expect(gateway, 'Imported gateway should appear in status').toBeDefined(); + }, + 120_000 + ); + + it.skipIf(!canRun)( + 'agentcore.json has correct gateway fields', + async () => { + const configPath = join(projectPath, 'agentcore', 'agentcore.json'); + const config = JSON.parse(await readFile(configPath, 'utf-8')) as { + agentCoreGateways: { + name: string; + resourceName?: string; + description?: string; + authorizerType: string; + enableSemanticSearch: boolean; + exceptionLevel: string; + executionRoleArn?: string; + tags?: Record; + targets: { name: string; targetType: string; endpoint?: string }[]; + }[]; + }; + + expect(config.agentCoreGateways.length, 'Should have one gateway').toBe(1); + const gw = config.agentCoreGateways[0]!; + + expect(gw.name, 'Gateway name should be set').toBeTruthy(); + expect(gw.resourceName, 'resourceName should preserve AWS name').toBeTruthy(); + expect(gw.description).toBe('Bugbash gateway for import testing'); + expect(gw.authorizerType).toBe('NONE'); + expect(gw.enableSemanticSearch).toBe(true); + expect(gw.exceptionLevel).toBe('DEBUG'); + expect(gw.tags).toEqual({ env: 'bugbash', team: 'agentcore-cli' }); + + expect(gw.executionRoleArn, 'executionRoleArn should be preserved from AWS').toBeTruthy(); + expect(gw.executionRoleArn).toContain('bugbash-agentcore-role'); + + expect(gw.targets.length, 'Should have one target').toBe(1); + expect(gw.targets[0]!.name).toBe('mcpTarget'); + expect(gw.targets[0]!.targetType).toBe('mcpServer'); + expect(gw.targets[0]!.endpoint).toBe('https://mcp.exa.ai/mcp'); + }, + 120_000 + ); + + it.skipIf(!canRun)( + 'deployed-state.json has gateway entry', + async () => { + const statePath = join(projectPath, 'agentcore', '.cli', 'deployed-state.json'); + const state = JSON.parse(await readFile(statePath, 'utf-8')) as Record; + + const targets = state.targets as Record } } }>; + const targetEntries = Object.values(targets); + expect(targetEntries.length).toBeGreaterThan(0); + + const firstTarget = targetEntries[0]!; + const gateways = firstTarget.resources?.mcp?.gateways; + expect(gateways, 'deployed-state should have mcp.gateways entry').toBeDefined(); + + const gatewayEntries = Object.values(gateways!); + expect(gatewayEntries.length, 'Should have one gateway in deployed state').toBe(1); + + const gwState = gatewayEntries[0] as { gatewayId?: string; gatewayArn?: string }; + expect(gwState.gatewayId, 'Gateway ID should be recorded').toBeTruthy(); + }, + 120_000 + ); +}); diff --git a/e2e-tests/import-resources.test.ts b/e2e-tests/import-resources.test.ts index e97f62f42..72d9c253a 100644 --- a/e2e-tests/import-resources.test.ts +++ b/e2e-tests/import-resources.test.ts @@ -9,7 +9,7 @@ import { spawnAndCollect, stripAnsi, } from '../src/test-utils/index.js'; -import { installCdkTarball, runAgentCoreCLI, writeAwsTargets } from './e2e-helper.js'; +import { dumpImportDebugInfo, installCdkTarball, runAgentCoreCLI, writeAwsTargets } from './e2e-helper.js'; import { execSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rm } from 'node:fs/promises'; @@ -54,6 +54,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { const result = await spawnAndCollect('uv', ['run', '--with', 'boto3', 'python3', script], fixtureDir, { AWS_REGION: region, DEFAULT_EVALUATOR_MODEL, + RESOURCE_SUFFIX: suffix, }); if (result.exitCode !== 0) { throw new Error( @@ -63,7 +64,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { } // 2. Read resource ARNs from bugbash-resources.json - const resourcesPath = join(fixtureDir, 'bugbash-resources.json'); + const resourcesPath = join(fixtureDir, `bugbash-resources-${suffix}.json`); const resources = JSON.parse(await readFile(resourcesPath, 'utf-8')) as Record; runtimeArn = resources['runtime-basic']!.arn; memoryArn = resources['memory-full']!.arn; @@ -102,6 +103,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { try { await spawnAndCollect('uv', ['run', '--with', 'boto3', 'python3', 'cleanup_resources.py'], fixtureDir, { AWS_REGION: region, + RESOURCE_SUFFIX: suffix, }); } catch { /* ignore — resources may already be deleted by CFN teardown */ @@ -112,6 +114,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { }, 600_000); const run = (args: string[]): Promise => runAgentCoreCLI(args, projectPath); + const stackName = `AgentCore-${agentName}-default`; // ── Import tests ────────────────────────────────────────────────── @@ -121,8 +124,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { const result = await run(['import', 'runtime', '--arn', runtimeArn, '--code', appDir, '--name', agentName, '-y']); if (result.exitCode !== 0) { - console.log('Import runtime stdout:', result.stdout); - console.log('Import runtime stderr:', result.stderr); + await dumpImportDebugInfo('runtime', result, projectPath, stackName, region); } expect(result.exitCode, `Import runtime failed: ${result.stderr}`).toBe(0); @@ -137,8 +139,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { const result = await run(['import', 'memory', '--arn', memoryArn, '-y']); if (result.exitCode !== 0) { - console.log('Import memory stdout:', result.stdout); - console.log('Import memory stderr:', result.stderr); + await dumpImportDebugInfo('memory', result, projectPath, stackName, region); } expect(result.exitCode, `Import memory failed: ${result.stderr}`).toBe(0); @@ -153,8 +154,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { const result = await run(['import', 'evaluator', '--arn', evaluatorArn]); if (result.exitCode !== 0) { - console.log('Import evaluator stdout:', result.stdout); - console.log('Import evaluator stderr:', result.stderr); + await dumpImportDebugInfo('evaluator', result, projectPath, stackName, region); } expect(result.exitCode, `Import evaluator failed: ${result.stderr}`).toBe(0); diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 91e557270..2cbd5b81f 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -51,7 +51,7 @@ await esbuild.build({ jsx: 'automatic', // Inject require shim for ESM compatibility with CommonJS dependencies banner: { - js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`, + js: `import { createRequire } from 'module'; import { fileURLToPath as __ef } from 'url'; import { dirname as __ed } from 'path'; const require = createRequire(import.meta.url); const __filename = __ef(import.meta.url); const __dirname = __ed(__filename);`, }, external: ['fsevents', '@aws-cdk/toolkit-lib'], plugins: [optionalDepsPlugin, textLoaderPlugin], diff --git a/integ-tests/README.md b/integ-tests/README.md index 729977988..66a117d12 100644 --- a/integ-tests/README.md +++ b/integ-tests/README.md @@ -1,64 +1,105 @@ # Integration Tests -This directory contains real AWS integration tests that actually deploy resources. +This directory contains integration tests that run the real CLI binary and assert on what it produces locally — no AWS +credentials, no network access, no deployed resources. + +## What Integration Tests Cover + +Integration tests verify that CLI commands behave correctly by checking: + +- **Exit code and stdout** — the command exits `0` on success, non-zero on failure, and `--json` output has the correct + shape +- **`agentcore/agentcore.json`** — the project config was mutated correctly after `add`, `remove`, or `create` commands +- **Scaffolded files** — `app/{agent}/pyproject.toml` contains the right framework dependencies, `app/{agent}/main.py` + exists, `.git/` was initialized +- **Validation behavior** — the CLI rejects invalid input with the right error message before making any network call + +They do **not** verify deployments, live AWS state, or agent invocation. Those belong in `e2e-tests/`. ## Prerequisites -- AWS credentials configured -- Sufficient IAM permissions to create/delete CloudFormation stacks -- A dedicated test AWS account (recommended) +- `npm` and `git` on PATH (some tests skip automatically if missing via `describe.skipIf`) +- `uv` on PATH (required for tests that scaffold Python agents) +- No AWS credentials needed -## Running Integration Tests +## Running ```bash # Run all integration tests npm run test:integ -# Run a specific test -npx vitest run integ-tests/deploy.test.ts --testTimeout=300000 +# Run a specific file +npx vitest run integ-tests/add-remove-gateway.test.ts ``` -## Test Naming Convention +## Writing Integration Tests -All integration test files should be prefixed with `integ.`: +```typescript +import { createTestProject, readProjectConfig, runCLI } from '../src/test-utils/index.js'; +import type { TestProject } from '../src/test-utils/index.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -- `integ.deploy.ts` - Tests actual deployment -- `integ.invoke.ts` - Tests invoking deployed agents -- `integ.destroy.ts` - Tests stack destruction -- `integ.e2e.ts` - Full end-to-end lifecycle test +describe('integration: add and remove a gateway', () => { + let project: TestProject; -## CI/CD + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + }); -Integration tests are NOT run automatically on every PR. They can be triggered: + afterAll(async () => { + await project.cleanup(); + }); -1. Manually via GitHub Actions workflow_dispatch -2. On a schedule (if configured) -3. Before releases + it('adds a gateway', async () => { + const result = await runCLI(['add', 'gateway', '--name', 'MyGateway', '--json'], project.projectPath); -## Writing Integration Tests + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout).success).toBe(true); -```typescript -import { runCLI } from '../src/test-utils/cli-runner'; -import { afterAll, describe, expect, it } from 'vitest'; + const config = await readProjectConfig(project.projectPath); + const gateway = config.agentCoreGateways?.find(g => g.name === 'MyGateway'); + expect(gateway).toBeTruthy(); + }); -describe('integ: deploy', () => { - // Use unique stack names to avoid conflicts - const stackName = `test-${Date.now()}`; + it('removes the gateway', async () => { + const result = await runCLI(['remove', 'gateway', '--name', 'MyGateway', '--json'], project.projectPath); - afterAll(async () => { - // ALWAYS clean up - destroy the stack - await runCLI(['destroy', '--target', stackName, '--force'], projectDir); - }); + expect(result.exitCode).toBe(0); - it('deploys successfully', async () => { - // Test implementation + const config = await readProjectConfig(project.projectPath); + expect(config.agentCoreGateways?.find(g => g.name === 'MyGateway')).toBeFalsy(); }); }); ``` -## Important Notes +### Key patterns + +| Pattern | Why | +| ----------------------------------- | ------------------------------------------------------- | +| `createTestProject()` | Fast temp project setup — no npm/uv install | +| `runCLI([...args], projectPath)` | Runs the real built CLI binary, not a mock | +| `readProjectConfig(path)` | Reads and parses `agentcore/agentcore.json` | +| `afterAll(() => project.cleanup())` | Always delete the temp directory | +| `--json` flag | Makes stdout machine-readable for assertions | +| Assert exit code first | Fail fast with a useful message before asserting output | + +### File naming + +Name files after the feature area, not the command: + +- `add-remove-gateway.test.ts` — not `add.test.ts` +- `create-frameworks.test.ts` — not `create.test.ts` +- `lifecycle-config.test.ts` — not `flags.test.ts` -- Integration tests create real AWS resources and may incur costs -- Always include cleanup in `after()` hooks -- Use unique names to avoid conflicts with parallel runs -- Set appropriate timeouts (5-15 minutes for deploy operations) +### No mocking + +Integration tests contain zero mocks. The CLI commands tested here make no network calls, so there is nothing to +intercept. The real binary runs against the real filesystem. + +## CI/CD + +Integration tests are not run automatically on every PR. They can be triggered: + +1. Manually via GitHub Actions `workflow_dispatch` +2. On a schedule (if configured) +3. Before releases diff --git a/integ-tests/add-remove-ab-test-target-based.test.ts b/integ-tests/add-remove-ab-test-target-based.test.ts new file mode 100644 index 000000000..8a77b1f06 --- /dev/null +++ b/integ-tests/add-remove-ab-test-target-based.test.ts @@ -0,0 +1,461 @@ +import { + type TestProject, + createTestProject, + parseJsonOutput, + readProjectConfig, + runCLI, +} from '../src/test-utils/index.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +async function runSuccess(args: string[], cwd: string) { + const result = await runCLI(args, cwd); + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', true); + return json as Record; +} + +async function runFailure(args: string[], cwd: string) { + const result = await runCLI(args, cwd); + expect(result.exitCode).toBe(1); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', false); + expect(json).toHaveProperty('error'); + return json as Record; +} + +describe('integration: add and remove target-based ab-test', () => { + let project: TestProject; + const gatewayName = 'my-test-gw'; + + beforeAll(async () => { + project = await createTestProject({ + name: 'TargetABTest', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + + // Add runtime endpoints (prod and staging) for the agent + await runSuccess( + ['add', 'runtime-endpoint', '--runtime', project.agentName, '--endpoint', 'prod', '--version', '1', '--json'], + project.projectPath + ); + await runSuccess( + ['add', 'runtime-endpoint', '--runtime', project.agentName, '--endpoint', 'staging', '--version', '1', '--json'], + project.projectPath + ); + + // Add an evaluator and two online eval configs (one per variant) + await runSuccess( + [ + 'add', + 'evaluator', + '--name', + 'TestEval', + '--level', + 'SESSION', + '--model', + 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + '--instructions', + 'Evaluate quality. Context: {context}', + '--json', + ], + project.projectPath + ); + await runSuccess( + [ + 'add', + 'online-eval', + '--name', + 'ControlEval', + '--runtime', + project.agentName, + '--evaluator', + 'TestEval', + '--sampling-rate', + '100', + '--endpoint', + 'prod', + '--json', + ], + project.projectPath + ); + await runSuccess( + [ + 'add', + 'online-eval', + '--name', + 'TreatmentEval', + '--runtime', + project.agentName, + '--evaluator', + 'TestEval', + '--sampling-rate', + '100', + '--endpoint', + 'staging', + '--json', + ], + project.projectPath + ); + }, 120000); + + afterAll(async () => { + await project.cleanup(); + }); + + it('adds target-based AB test with --control-endpoint and --treatment-endpoint', async () => { + const json = await runSuccess( + [ + 'add', + 'ab-test', + '--mode', + 'target-based', + '--name', + 'TargetTest1', + '--runtime', + project.agentName, + '--gateway', + gatewayName, + '--control-endpoint', + 'prod', + '--treatment-endpoint', + 'staging', + '--control-weight', + '90', + '--treatment-weight', + '10', + '--control-online-eval', + 'ControlEval', + '--treatment-online-eval', + 'TreatmentEval', + '--json', + ], + project.projectPath + ); + + expect(json.abTestName).toBe('TargetTest1'); + + // Verify agentcore.json has correct mode, targets, gateway auto-created + const spec = await readProjectConfig(project.projectPath); + const abTest = spec.abTests?.find((t: { name: string }) => t.name === 'TargetTest1'); + expect(abTest).toBeDefined(); + expect(abTest!.mode).toBe('target-based'); + expect(abTest!.variants).toHaveLength(2); + expect(abTest!.variants[0]!.name).toBe('C'); + expect(abTest!.variants[0]!.weight).toBe(90); + expect(abTest!.variants[0]!.variantConfiguration.target).toBeDefined(); + expect(abTest!.variants[0]!.variantConfiguration.target!.targetName).toBe(`${project.agentName}-prod`); + expect(abTest!.variants[1]!.name).toBe('T1'); + expect(abTest!.variants[1]!.weight).toBe(10); + expect(abTest!.variants[1]!.variantConfiguration.target!.targetName).toBe(`${project.agentName}-staging`); + expect(abTest!.gatewayRef).toBe(`{{gateway:${gatewayName}}}`); + + // Verify gateway was auto-created with targets + const gw = spec.httpGateways?.find((g: { name: string }) => g.name === gatewayName); + expect(gw, 'HTTP gateway should have been auto-created').toBeDefined(); + expect(gw!.targets).toBeDefined(); + expect(gw!.targets!.length).toBeGreaterThanOrEqual(2); + + const controlTarget = gw!.targets!.find((t: { name: string }) => t.name === `${project.agentName}-prod`); + expect(controlTarget).toBeDefined(); + expect(controlTarget!.qualifier).toBe('prod'); + + const treatmentTarget = gw!.targets!.find((t: { name: string }) => t.name === `${project.agentName}-staging`); + expect(treatmentTarget).toBeDefined(); + expect(treatmentTarget!.qualifier).toBe('staging'); + + // Verify per-variant evaluation config + const evalConfig = abTest!.evaluationConfig; + expect('perVariantOnlineEvaluationConfig' in evalConfig).toBe(true); + if ('perVariantOnlineEvaluationConfig' in evalConfig) { + expect(evalConfig.perVariantOnlineEvaluationConfig).toHaveLength(2); + const controlEval = evalConfig.perVariantOnlineEvaluationConfig.find( + (p: { treatmentName: string }) => p.treatmentName === 'C' + ); + expect(controlEval?.onlineEvaluationConfigArn).toBe('ControlEval'); + const treatmentEval = evalConfig.perVariantOnlineEvaluationConfig.find( + (p: { treatmentName: string }) => p.treatmentName === 'T1' + ); + expect(treatmentEval?.onlineEvaluationConfigArn).toBe('TreatmentEval'); + } + }); + + it('adds target-based AB test with existing gateway', async () => { + // TargetTest1 already created the gateway — reuse it + const json = await runSuccess( + [ + 'add', + 'ab-test', + '--mode', + 'target-based', + '--name', + 'TargetTest2', + '--runtime', + project.agentName, + '--gateway', + gatewayName, + '--control-endpoint', + 'prod', + '--treatment-endpoint', + 'staging', + '--control-weight', + '50', + '--treatment-weight', + '50', + '--control-online-eval', + 'ControlEval', + '--treatment-online-eval', + 'TreatmentEval', + '--json', + ], + project.projectPath + ); + + expect(json.abTestName).toBe('TargetTest2'); + + const spec = await readProjectConfig(project.projectPath); + // Gateway should still exist (reused, not duplicated) + const gateways = spec.httpGateways?.filter((g: { name: string }) => g.name === gatewayName); + expect(gateways).toHaveLength(1); + }); + + it('rejects duplicate AB test name', async () => { + const json = await runFailure( + [ + 'add', + 'ab-test', + '--mode', + 'target-based', + '--name', + 'TargetTest1', + '--runtime', + project.agentName, + '--gateway', + gatewayName, + '--control-endpoint', + 'prod', + '--treatment-endpoint', + 'staging', + '--control-weight', + '50', + '--treatment-weight', + '50', + '--control-online-eval', + 'ControlEval', + '--treatment-online-eval', + 'TreatmentEval', + '--json', + ], + project.projectPath + ); + + expect(json.error).toContain('already exists'); + }); + + it('rejects weights that do not sum to 100', async () => { + const json = await runFailure( + [ + 'add', + 'ab-test', + '--mode', + 'target-based', + '--name', + 'BadWeights', + '--runtime', + project.agentName, + '--gateway', + gatewayName, + '--control-endpoint', + 'prod', + '--treatment-endpoint', + 'staging', + '--control-weight', + '80', + '--treatment-weight', + '80', + '--control-online-eval', + 'ControlEval', + '--treatment-online-eval', + 'TreatmentEval', + '--json', + ], + project.projectPath + ); + + expect(json.error).toBeDefined(); + }); + + it('errors when --control-endpoint is missing in target-based mode', async () => { + const json = await runFailure( + [ + 'add', + 'ab-test', + '--mode', + 'target-based', + '--name', + 'MissingControl', + '--runtime', + project.agentName, + '--gateway', + gatewayName, + '--treatment-endpoint', + 'staging', + '--control-weight', + '50', + '--treatment-weight', + '50', + '--control-online-eval', + 'ControlEval', + '--treatment-online-eval', + 'TreatmentEval', + '--json', + ], + project.projectPath + ); + + expect(json.error).toContain('--control-endpoint'); + }); + + it('errors when --runtime is missing in target-based mode', async () => { + const json = await runFailure( + [ + 'add', + 'ab-test', + '--mode', + 'target-based', + '--name', + 'MissingRuntime', + '--gateway', + gatewayName, + '--control-endpoint', + 'prod', + '--treatment-endpoint', + 'staging', + '--control-weight', + '50', + '--treatment-weight', + '50', + '--control-online-eval', + 'ControlEval', + '--treatment-online-eval', + 'TreatmentEval', + '--json', + ], + project.projectPath + ); + + expect(json.error).toContain('--runtime'); + }); + + it('errors when endpoint does not exist on runtime', async () => { + const json = await runFailure( + [ + 'add', + 'ab-test', + '--mode', + 'target-based', + '--name', + 'BadEndpoint', + '--runtime', + project.agentName, + '--gateway', + gatewayName, + '--control-endpoint', + 'nonexistent', + '--treatment-endpoint', + 'staging', + '--control-weight', + '50', + '--treatment-weight', + '50', + '--control-online-eval', + 'ControlEval', + '--treatment-online-eval', + 'TreatmentEval', + '--json', + ], + project.projectPath + ); + + expect(json.error).toContain('nonexistent'); + }); + + it('deprecated --control-qualifier still works as alias for --control-endpoint', async () => { + const json = await runSuccess( + [ + 'add', + 'ab-test', + '--mode', + 'target-based', + '--name', + 'QualifierAlias', + '--runtime', + project.agentName, + '--gateway', + gatewayName, + '--control-qualifier', + 'prod', + '--treatment-qualifier', + 'staging', + '--control-weight', + '60', + '--treatment-weight', + '40', + '--control-online-eval', + 'ControlEval', + '--treatment-online-eval', + 'TreatmentEval', + '--json', + ], + project.projectPath + ); + + expect(json.abTestName).toBe('QualifierAlias'); + + const spec = await readProjectConfig(project.projectPath); + const abTest = spec.abTests?.find((t: { name: string }) => t.name === 'QualifierAlias'); + expect(abTest).toBeDefined(); + expect(abTest!.mode).toBe('target-based'); + expect(abTest!.variants[0]!.variantConfiguration.target!.targetName).toBe(`${project.agentName}-prod`); + expect(abTest!.variants[1]!.variantConfiguration.target!.targetName).toBe(`${project.agentName}-staging`); + }); + + it('removes target-based AB test without --delete-gateway', async () => { + const json = await runSuccess(['remove', 'ab-test', '--name', 'TargetTest2', '--json'], project.projectPath); + expect(json.success).toBe(true); + + // Verify removal from agentcore.json + const spec = await readProjectConfig(project.projectPath); + const abTest = spec.abTests?.find((t: { name: string }) => t.name === 'TargetTest2'); + expect(abTest).toBeUndefined(); + + // Gateway should still exist (other AB tests reference it) + const gw = spec.httpGateways?.find((g: { name: string }) => g.name === gatewayName); + expect(gw, 'Gateway should still exist when other AB tests reference it').toBeDefined(); + }); + + it('removes target-based AB test with --delete-gateway flag', async () => { + // First remove QualifierAlias so only TargetTest1 is left referencing the gateway + await runSuccess(['remove', 'ab-test', '--name', 'QualifierAlias', '--json'], project.projectPath); + + // Now remove TargetTest1 with --delete-gateway + const json = await runSuccess( + ['remove', 'ab-test', '--name', 'TargetTest1', '--delete-gateway', '--json'], + project.projectPath + ); + expect(json.success).toBe(true); + + // Verify gateway was also removed (no other AB tests reference it) + const spec = await readProjectConfig(project.projectPath); + const gw = spec.httpGateways?.find((g: { name: string }) => g.name === gatewayName); + expect(gw, 'Gateway should be removed with --delete-gateway when no other AB tests reference it').toBeUndefined(); + }); + + it('remove returns error for non-existent test', async () => { + const json = await runFailure(['remove', 'ab-test', '--name', 'DoesNotExist', '--json'], project.projectPath); + expect(json.error).toContain('not found'); + }); +}); diff --git a/integ-tests/add-remove-ab-test.test.ts b/integ-tests/add-remove-ab-test.test.ts new file mode 100644 index 000000000..551c86010 --- /dev/null +++ b/integ-tests/add-remove-ab-test.test.ts @@ -0,0 +1,170 @@ +import { + type TestProject, + createTestProject, + parseJsonOutput, + readProjectConfig, + runCLI, +} from '../src/test-utils/index.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +async function runSuccess(args: string[], cwd: string) { + const result = await runCLI(args, cwd); + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', true); + return json as Record; +} + +async function runFailure(args: string[], cwd: string) { + const result = await runCLI(args, cwd); + expect(result.exitCode).toBe(1); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', false); + expect(json).toHaveProperty('error'); + return json as Record; +} + +describe('integration: add and remove ab-test', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + it('requires --name for JSON mode', async () => { + const json = await runFailure(['add', 'ab-test', '--json'], project.projectPath); + expect(json.error).toContain('--name'); + }); + + it('requires --runtime when --name is provided', async () => { + const json = await runFailure(['add', 'ab-test', '--name', 'Test1', '--json'], project.projectPath); + expect(json.error).toContain('--runtime'); + }); + + it('adds ab-test with all required flags', async () => { + const json = await runSuccess( + [ + 'add', + 'ab-test', + '--name', + 'MyIntegTest', + '--runtime', + project.agentName, + '--control-bundle', + 'arn:bundle:control', + '--control-version', + 'v1', + '--treatment-bundle', + 'arn:bundle:treatment', + '--treatment-version', + 'v1', + '--control-weight', + '80', + '--treatment-weight', + '20', + '--online-eval', + 'arn:eval:config', + '--json', + ], + project.projectPath + ); + + expect(json.abTestName).toBe('MyIntegTest'); + + // Verify it's in agentcore.json with correct structure + const spec = await readProjectConfig(project.projectPath); + const abTest = spec.abTests?.find((t: { name: string }) => t.name === 'MyIntegTest'); + expect(abTest).toBeDefined(); + expect(abTest!.variants).toHaveLength(2); + expect(abTest!.variants[0]!.name).toBe('C'); + expect(abTest!.variants[0]!.weight).toBe(80); + expect(abTest!.variants[1]!.name).toBe('T1'); + expect(abTest!.variants[1]!.weight).toBe(20); + }); + + it('rejects duplicate AB test name', async () => { + const json = await runFailure( + [ + 'add', + 'ab-test', + '--name', + 'MyIntegTest', + '--runtime', + project.agentName, + '--control-bundle', + 'arn:cb', + '--control-version', + 'v1', + '--treatment-bundle', + 'arn:tb', + '--treatment-version', + 'v1', + '--control-weight', + '50', + '--treatment-weight', + '50', + '--online-eval', + 'arn:eval', + '--json', + ], + project.projectPath + ); + + expect(json.error).toContain('already exists'); + }); + + it('rejects weights that do not sum to 100', async () => { + const json = await runFailure( + [ + 'add', + 'ab-test', + '--name', + 'BadWeights', + '--runtime', + project.agentName, + '--control-bundle', + 'arn:cb', + '--control-version', + 'v1', + '--treatment-bundle', + 'arn:tb', + '--treatment-version', + 'v1', + '--control-weight', + '80', + '--treatment-weight', + '80', + '--online-eval', + 'arn:eval', + '--json', + ], + project.projectPath + ); + + expect(json.error).toBeDefined(); + }); + + it('removes ab-test', async () => { + const json = await runSuccess(['remove', 'ab-test', '--name', 'MyIntegTest', '--json'], project.projectPath); + expect(json.success).toBe(true); + + // Verify removal from agentcore.json + const spec = await readProjectConfig(project.projectPath); + const abTest = spec.abTests?.find((t: { name: string }) => t.name === 'MyIntegTest'); + expect(abTest).toBeUndefined(); + }); + + it('remove returns error for non-existent test', async () => { + const json = await runFailure(['remove', 'ab-test', '--name', 'DoesNotExist', '--json'], project.projectPath); + expect(json.error).toContain('not found'); + }); +}); diff --git a/integ-tests/add-remove-config-bundle.test.ts b/integ-tests/add-remove-config-bundle.test.ts new file mode 100644 index 000000000..bd53e7f31 --- /dev/null +++ b/integ-tests/add-remove-config-bundle.test.ts @@ -0,0 +1,312 @@ +import { + type TestProject, + createTestProject, + parseJsonOutput, + readProjectConfig, + runCLI, + runFailure, + runSuccess, +} from '../src/test-utils/index.js'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('integration: add and remove config-bundle', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + // ── Add lifecycle ───────────────────────────────────────────────────── + + describe('add config-bundle', () => { + it('adds a config bundle with inline --components', async () => { + const components = JSON.stringify({ + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/rt-abc': { + configuration: { systemPrompt: 'You are a helpful assistant.' }, + }, + }); + + const json = await runSuccess( + ['add', 'config-bundle', '--name', 'InlineBundle', '--components', components, '--json'], + project.projectPath + ); + + expect(json.bundleName).toBe('InlineBundle'); + + const config = await readProjectConfig(project.projectPath); + const bundle = config.configBundles!.find(b => b.name === 'InlineBundle'); + expect(bundle).toBeDefined(); + expect(bundle!.type).toBe('ConfigurationBundle'); + expect(bundle!.branchName).toBe('mainline'); + expect(Object.keys(bundle!.components)).toHaveLength(1); + }); + + it('adds a config bundle with --components-file', async () => { + const componentsData = { + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/rt-def': { + configuration: { temperature: 0.7, maxTokens: 1024 }, + }, + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-xyz': { + configuration: { rateLimit: 100 }, + }, + }; + + const filePath = join(project.projectPath, 'test-components.json'); + await writeFile(filePath, JSON.stringify(componentsData)); + + const json = await runSuccess( + ['add', 'config-bundle', '--name', 'FileBundle', '--components-file', filePath, '--json'], + project.projectPath + ); + + expect(json.bundleName).toBe('FileBundle'); + + const config = await readProjectConfig(project.projectPath); + const bundle = config.configBundles!.find(b => b.name === 'FileBundle'); + expect(bundle).toBeDefined(); + expect(Object.keys(bundle!.components)).toHaveLength(2); + }); + + it('adds a config bundle with optional description, branch, and commit message', async () => { + const components = JSON.stringify({ + '{{runtime:MyAgent}}': { + configuration: { systemPrompt: 'Placeholder-based bundle' }, + }, + }); + + const json = await runSuccess( + [ + 'add', + 'config-bundle', + '--name', + 'FullOptsBundle', + '--description', + 'A bundle with all optional fields', + '--components', + components, + '--branch', + 'feature-branch', + '--commit-message', + 'initial config', + '--json', + ], + project.projectPath + ); + + expect(json.bundleName).toBe('FullOptsBundle'); + + const config = await readProjectConfig(project.projectPath); + const bundle = config.configBundles!.find(b => b.name === 'FullOptsBundle'); + expect(bundle).toBeDefined(); + expect(bundle!.description).toBe('A bundle with all optional fields'); + expect(bundle!.branchName).toBe('feature-branch'); + expect(bundle!.commitMessage).toBe('initial config'); + }); + + it('adds a config bundle with placeholder component keys', async () => { + const components = JSON.stringify({ + '{{runtime:AgentA}}': { + configuration: { systemPrompt: 'Runtime placeholder' }, + }, + '{{gateway:GatewayB}}': { + configuration: { rateLimitPerSecond: 50 }, + }, + }); + + const json = await runSuccess( + ['add', 'config-bundle', '--name', 'PlaceholderBundle', '--components', components, '--json'], + project.projectPath + ); + + expect(json.bundleName).toBe('PlaceholderBundle'); + + const config = await readProjectConfig(project.projectPath); + const bundle = config.configBundles!.find(b => b.name === 'PlaceholderBundle'); + expect(bundle).toBeDefined(); + const keys = Object.keys(bundle!.components); + expect(keys).toContain('{{runtime:AgentA}}'); + expect(keys).toContain('{{gateway:GatewayB}}'); + }); + }); + + // ── Validation / error cases ────────────────────────────────────────── + + describe('validation errors', () => { + it('rejects duplicate config bundle name', async () => { + const components = JSON.stringify({ + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/rt-dup': { + configuration: { foo: 'bar' }, + }, + }); + + const json = await runFailure( + ['add', 'config-bundle', '--name', 'InlineBundle', '--components', components, '--json'], + project.projectPath + ); + + expect(json.error).toContain('already exists'); + }); + + it('requires --name in non-interactive (JSON) mode', async () => { + const result = await runCLI( + ['add', 'config-bundle', '--components', '{"arn:test": {"configuration": {}}}', '--json'], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('--name'); + }); + + it('requires --components or --components-file when --name is provided', async () => { + const json = await runFailure(['add', 'config-bundle', '--name', 'NoComponents', '--json'], project.projectPath); + + expect(json.error).toContain('--components'); + }); + + it('rejects invalid JSON in --components', async () => { + const result = await runCLI( + ['add', 'config-bundle', '--name', 'BadJson', '--components', '{not valid json}', '--json'], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + }); + + it('rejects --components-file with non-existent file', async () => { + const result = await runCLI( + [ + 'add', + 'config-bundle', + '--name', + 'MissingFile', + '--components-file', + '/tmp/does-not-exist-xyz.json', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + }); + + it('rejects bundle name with invalid characters', async () => { + const components = JSON.stringify({ + 'arn:test': { configuration: {} }, + }); + + const json = await runFailure( + ['add', 'config-bundle', '--name', 'invalid-name!', '--components', components, '--json'], + project.projectPath + ); + + expect(json.error).toBeDefined(); + }); + + it('rejects bundle name starting with a number', async () => { + const components = JSON.stringify({ + 'arn:test': { configuration: {} }, + }); + + const json = await runFailure( + ['add', 'config-bundle', '--name', '1BadName', '--components', components, '--json'], + project.projectPath + ); + + expect(json.error).toBeDefined(); + }); + }); + + // ── Remove lifecycle ────────────────────────────────────────────────── + + describe('remove config-bundle', () => { + it('removes an existing config bundle', async () => { + const json = await runSuccess( + ['remove', 'config-bundle', '--name', 'InlineBundle', '--json'], + project.projectPath + ); + + expect(json.success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const bundle = config.configBundles!.find(b => b.name === 'InlineBundle'); + expect(bundle).toBeUndefined(); + }); + + it('returns error for non-existent bundle', async () => { + const json = await runFailure( + ['remove', 'config-bundle', '--name', 'DoesNotExist', '--json'], + project.projectPath + ); + + expect(json.error).toContain('not found'); + }); + + it('removes all remaining config bundles one by one', async () => { + const configBefore = await readProjectConfig(project.projectPath); + const remaining = configBefore.configBundles!.map(b => b.name); + + for (const name of remaining) { + await runSuccess(['remove', 'config-bundle', '--name', name, '--json'], project.projectPath); + } + + const configAfter = await readProjectConfig(project.projectPath); + expect(configAfter.configBundles!).toHaveLength(0); + }); + }); + + // ── Multiple bundles coexistence ────────────────────────────────────── + + describe('multiple bundles coexistence', () => { + const bundleNames = ['BundleAlpha', 'BundleBeta', 'BundleGamma']; + + it('can add multiple config bundles to the same project', async () => { + for (const name of bundleNames) { + const components = JSON.stringify({ + [`arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/${name}`]: { + configuration: { bundleId: name }, + }, + }); + + await runSuccess( + ['add', 'config-bundle', '--name', name, '--components', components, '--json'], + project.projectPath + ); + } + + const config = await readProjectConfig(project.projectPath); + expect(config.configBundles!).toHaveLength(bundleNames.length); + + for (const name of bundleNames) { + expect(config.configBundles!.find(b => b.name === name)).toBeDefined(); + } + }); + + it('removing one bundle does not affect others', async () => { + await runSuccess(['remove', 'config-bundle', '--name', 'BundleBeta', '--json'], project.projectPath); + + const config = await readProjectConfig(project.projectPath); + expect(config.configBundles!).toHaveLength(2); + expect(config.configBundles!.find(b => b.name === 'BundleAlpha')).toBeDefined(); + expect(config.configBundles!.find(b => b.name === 'BundleGamma')).toBeDefined(); + expect(config.configBundles!.find(b => b.name === 'BundleBeta')).toBeUndefined(); + }); + + afterAll(async () => { + for (const name of bundleNames) { + try { + await runCLI(['remove', 'config-bundle', '--name', name, '--json'], project.projectPath); + } catch { + // already removed + } + } + }); + }); +}); diff --git a/integ-tests/add-remove-online-eval-endpoint.test.ts b/integ-tests/add-remove-online-eval-endpoint.test.ts new file mode 100644 index 000000000..cb2a614c8 --- /dev/null +++ b/integ-tests/add-remove-online-eval-endpoint.test.ts @@ -0,0 +1,199 @@ +import { + type TestProject, + createTestProject, + parseJsonOutput, + readProjectConfig, + runCLI, +} from '../src/test-utils/index.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +async function runSuccess(args: string[], cwd: string) { + const result = await runCLI(args, cwd); + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', true); + return json as Record; +} + +async function runFailure(args: string[], cwd: string) { + const result = await runCLI(args, cwd); + expect(result.exitCode).toBe(1); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', false); + expect(json).toHaveProperty('error'); + return json as Record; +} + +describe('integration: add and remove online-eval with endpoint', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ + name: 'OnlineEvalEP', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + + // Add runtime endpoints (prod and staging) for the agent + await runSuccess( + ['add', 'runtime-endpoint', '--runtime', project.agentName, '--endpoint', 'prod', '--version', '1', '--json'], + project.projectPath + ); + await runSuccess( + ['add', 'runtime-endpoint', '--runtime', project.agentName, '--endpoint', 'staging', '--version', '1', '--json'], + project.projectPath + ); + + // Add an evaluator to reference in online eval configs + await runSuccess( + [ + 'add', + 'evaluator', + '--name', + 'QualityEval', + '--level', + 'SESSION', + '--model', + 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + '--instructions', + 'Evaluate quality. Context: {context}', + '--json', + ], + project.projectPath + ); + }, 120000); + + afterAll(async () => { + await project.cleanup(); + }); + + it('adds online eval with --endpoint prod', async () => { + const json = await runSuccess( + [ + 'add', + 'online-eval', + '--name', + 'ProdEval', + '--runtime', + project.agentName, + '--evaluator', + 'QualityEval', + '--sampling-rate', + '100', + '--endpoint', + 'prod', + '--json', + ], + project.projectPath + ); + + expect(json.configName).toBe('ProdEval'); + + // Verify agentcore.json has endpoint field + const spec = await readProjectConfig(project.projectPath); + const evalConfig = spec.onlineEvalConfigs?.find((c: { name: string }) => c.name === 'ProdEval'); + expect(evalConfig).toBeDefined(); + expect(evalConfig!.endpoint).toBe('prod'); + expect(evalConfig!.agent).toBe(project.agentName); + expect(evalConfig!.evaluators).toContain('QualityEval'); + expect(evalConfig!.samplingRate).toBe(100); + }); + + it('adds online eval with --endpoint staging', async () => { + const json = await runSuccess( + [ + 'add', + 'online-eval', + '--name', + 'StagingEval', + '--runtime', + project.agentName, + '--evaluator', + 'QualityEval', + '--sampling-rate', + '50', + '--endpoint', + 'staging', + '--json', + ], + project.projectPath + ); + + expect(json.configName).toBe('StagingEval'); + + const spec = await readProjectConfig(project.projectPath); + const evalConfig = spec.onlineEvalConfigs?.find((c: { name: string }) => c.name === 'StagingEval'); + expect(evalConfig).toBeDefined(); + expect(evalConfig!.endpoint).toBe('staging'); + }); + + it('adds online eval without --endpoint (no endpoint field in config)', async () => { + const json = await runSuccess( + [ + 'add', + 'online-eval', + '--name', + 'NoEndpointEval', + '--runtime', + project.agentName, + '--evaluator', + 'QualityEval', + '--sampling-rate', + '100', + '--json', + ], + project.projectPath + ); + + expect(json.configName).toBe('NoEndpointEval'); + + const spec = await readProjectConfig(project.projectPath); + const evalConfig = spec.onlineEvalConfigs?.find((c: { name: string }) => c.name === 'NoEndpointEval'); + expect(evalConfig).toBeDefined(); + expect(evalConfig!.endpoint).toBeUndefined(); + }); + + it('errors when endpoint does not exist on runtime', async () => { + const json = await runFailure( + [ + 'add', + 'online-eval', + '--name', + 'BadEndpointEval', + '--runtime', + project.agentName, + '--evaluator', + 'QualityEval', + '--sampling-rate', + '100', + '--endpoint', + 'nonexistent', + '--json', + ], + project.projectPath + ); + + expect(json.error).toContain('nonexistent'); + }); + + it('removes online eval config', async () => { + const json = await runSuccess(['remove', 'online-eval', '--name', 'ProdEval', '--json'], project.projectPath); + expect(json.success).toBe(true); + + // Verify removal from agentcore.json + const spec = await readProjectConfig(project.projectPath); + const evalConfig = spec.onlineEvalConfigs?.find((c: { name: string }) => c.name === 'ProdEval'); + expect(evalConfig).toBeUndefined(); + + // Other eval configs should remain + const stagingEval = spec.onlineEvalConfigs?.find((c: { name: string }) => c.name === 'StagingEval'); + expect(stagingEval).toBeDefined(); + }); + + it('remove returns error for non-existent online eval', async () => { + const json = await runFailure(['remove', 'online-eval', '--name', 'DoesNotExist', '--json'], project.projectPath); + expect(json.error).toContain('not found'); + }); +}); diff --git a/integ-tests/add-remove-resources.test.ts b/integ-tests/add-remove-resources.test.ts index 57dd48483..a89c761dd 100644 --- a/integ-tests/add-remove-resources.test.ts +++ b/integ-tests/add-remove-resources.test.ts @@ -1,7 +1,10 @@ import { createTestProject, readProjectConfig, runCLI } from '../src/test-utils/index.js'; import type { TestProject } from '../src/test-utils/index.js'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const telemetry = createTelemetryHelper(); + describe('integration: add and remove resources', () => { let project: TestProject; @@ -16,13 +19,16 @@ describe('integration: add and remove resources', () => { afterAll(async () => { await project.cleanup(); + telemetry.destroy(); }); describe('memory lifecycle', () => { const memoryName = `IntegMem${Date.now().toString().slice(-6)}`; it('adds a memory resource', async () => { - const result = await runCLI(['add', 'memory', '--name', memoryName, '--json'], project.projectPath); + const result = await runCLI(['add', 'memory', '--name', memoryName, '--json'], project.projectPath, { + env: telemetry.env, + }); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); const json = JSON.parse(result.stdout); @@ -34,13 +40,17 @@ describe('integration: add and remove resources', () => { expect(memories, 'memories should exist').toBeDefined(); const found = memories!.some((m: Record) => m.name === memoryName); expect(found, `Memory "${memoryName}" should be in config`).toBe(true); + + // Verify telemetry + telemetry.assertMetricEmitted({ command: 'add.memory', exit_reason: 'success' }); }); it('adds a memory with EPISODIC strategy and verifies reflectionNamespaces', async () => { const episodicMemName = `EpiMem${Date.now().toString().slice(-6)}`; const result = await runCLI( ['add', 'memory', '--name', episodicMemName, '--strategies', 'EPISODIC', '--json'], - project.projectPath + project.projectPath, + { env: telemetry.env } ); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); @@ -61,6 +71,14 @@ describe('integration: add and remove resources', () => { expect(episodic!.reflectionNamespaces, 'Should have reflectionNamespaces').toBeDefined(); expect(episodic!.reflectionNamespaces!.length).toBeGreaterThan(0); + // Verify telemetry + telemetry.assertMetricEmitted({ + command: 'add.memory', + exit_reason: 'success', + strategy_count: '1', + strategy_episodic: 'true', + }); + // Clean up await runCLI(['remove', 'memory', '--name', episodicMemName, '--json'], project.projectPath); }); @@ -86,7 +104,8 @@ describe('integration: add and remove resources', () => { it('adds a credential resource', async () => { const result = await runCLI( ['add', 'credential', '--name', credentialName, '--api-key', 'test-key-integ-123', '--json'], - project.projectPath + project.projectPath, + { env: telemetry.env } ); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); @@ -99,6 +118,13 @@ describe('integration: add and remove resources', () => { expect(credentials, 'credentials should exist').toBeDefined(); const found = credentials!.some((c: Record) => c.name === credentialName); expect(found, `Credential "${credentialName}" should be in config`).toBe(true); + + // Verify telemetry + telemetry.assertMetricEmitted({ + command: 'add.credential', + exit_reason: 'success', + credential_type: 'api-key', + }); }); it('removes the credential resource', async () => { @@ -115,4 +141,30 @@ describe('integration: add and remove resources', () => { expect(found, `Credential "${credentialName}" should be removed from config`).toBe(false); }); }); + + describe('policy-engine', () => { + const engineName = `TestEngine${Date.now().toString().slice(-6)}`; + + it('adds a policy engine resource', async () => { + const result = await runCLI(['add', 'policy-engine', '--name', engineName, '--json'], project.projectPath, { + env: telemetry.env, + }); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + telemetry.assertMetricEmitted({ + command: 'add.policy-engine', + exit_reason: 'success', + attach_gateway_count: '0', + }); + }); + + it('removes the policy engine resource', async () => { + const result = await runCLI(['remove', 'policy-engine', '--name', engineName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + }); + }); }); diff --git a/integ-tests/create-frameworks.test.ts b/integ-tests/create-frameworks.test.ts index dee93cc1e..82bbc0871 100644 --- a/integ-tests/create-frameworks.test.ts +++ b/integ-tests/create-frameworks.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable security/detect-non-literal-fs-filename */ import { exists, prereqs, readProjectConfig, runCLI } from '../src/test-utils/index.js'; import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rm } from 'node:fs/promises'; diff --git a/integ-tests/create-memory.test.ts b/integ-tests/create-memory.test.ts index 35cd4436d..ac80f1ba4 100644 --- a/integ-tests/create-memory.test.ts +++ b/integ-tests/create-memory.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable security/detect-non-literal-fs-filename */ import { prereqs, readProjectConfig, runCLI } from '../src/test-utils/index.js'; import { randomUUID } from 'node:crypto'; import { mkdir, rm } from 'node:fs/promises'; diff --git a/integ-tests/create-no-agent.test.ts b/integ-tests/create-no-agent.test.ts index 4bcca2690..bcdf80eaa 100644 --- a/integ-tests/create-no-agent.test.ts +++ b/integ-tests/create-no-agent.test.ts @@ -32,7 +32,7 @@ describe('integration: create without agent', () => { it.skipIf(!hasNpm || !hasGit)('creates project with real npm install and git init', async () => { const name = `NoAgent${Date.now().toString().slice(-6)}`; - const result = await runCLI(['create', '--name', name, '--no-agent', '--json'], testDir, false); + const result = await runCLI(['create', '--name', name, '--no-agent', '--json'], testDir, { skipInstall: false }); expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); diff --git a/integ-tests/create-protocols.test.ts b/integ-tests/create-protocols.test.ts index 30b707f8c..440050fdb 100644 --- a/integ-tests/create-protocols.test.ts +++ b/integ-tests/create-protocols.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable security/detect-non-literal-fs-filename */ import { exists, prereqs, readProjectConfig, runCLI } from '../src/test-utils/index.js'; import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rm } from 'node:fs/promises'; diff --git a/integ-tests/create-with-agent.test.ts b/integ-tests/create-with-agent.test.ts index 7fb20bdbf..69f0594b8 100644 --- a/integ-tests/create-with-agent.test.ts +++ b/integ-tests/create-with-agent.test.ts @@ -49,7 +49,7 @@ describe('integration: create with Python agent', () => { '--json', ], testDir, - false + { skipInstall: false } ); expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); diff --git a/integ-tests/dev-server.test.ts b/integ-tests/dev-server.test.ts index 5f60976e7..4b07b7284 100644 --- a/integ-tests/dev-server.test.ts +++ b/integ-tests/dev-server.test.ts @@ -60,7 +60,7 @@ describe('integration: dev server', () => { '--json', ], testDir, - false + { skipInstall: false } ); if (result.exitCode === 0) { diff --git a/integ-tests/help.test.ts b/integ-tests/help.test.ts index af99b8ce4..7e2176e2f 100644 --- a/integ-tests/help.test.ts +++ b/integ-tests/help.test.ts @@ -1,5 +1,9 @@ +import { spawnAndCollect } from '../src/test-utils/cli-runner.js'; import { runCLI } from '../src/test-utils/index.js'; -import { describe, expect, it } from 'vitest'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; const COMMANDS = [ 'create', @@ -38,3 +42,53 @@ describe('CLI help', () => { } }); }); + +describe('help modes telemetry', () => { + const telemetry = createTelemetryHelper(); + const cliPath = join(__dirname, '..', 'dist', 'cli', 'index.mjs'); + + afterAll(() => telemetry.destroy()); + + function run(args: string[], extraEnv: Record = {}) { + return spawnAndCollect('node', [cliPath, ...args], process.cwd(), { + AGENTCORE_SKIP_INSTALL: '1', + ...telemetry.env, + ...extraEnv, + }); + } + + it('writes JSONL audit file when audit is enabled via env var', async () => { + const result = await run(['help', 'modes']); + expect(result.exitCode).toBe(0); + + const entries = telemetry.readEntries(); + expect(entries).toHaveLength(1); + telemetry.assertMetricEmitted({ + command_group: 'help', + command: 'help.modes', + exit_reason: 'success', + }); + expect(entries[0]!.attrs['agentcore-cli.session_id']).toBeDefined(); + expect(entries[0]!.attrs['os.type']).toBeDefined(); + expect(entries[0]!.value).toBeGreaterThanOrEqual(0); + }); + + it('does not write audit file when audit is not enabled', async () => { + telemetry.clearEntries(); + + const noAuditCliPath = join(__dirname, '..', 'dist', 'cli', 'index.mjs'); + const result = await spawnAndCollect('node', [noAuditCliPath, 'help', 'modes'], process.cwd(), { + AGENTCORE_SKIP_INSTALL: '1', + AGENTCORE_CONFIG_DIR: telemetry.dir, + }); + expect(result.exitCode).toBe(0); + + const telemetryDir = join(telemetry.dir, 'telemetry'); + try { + const files = readdirSync(telemetryDir); + expect(files).toHaveLength(0); + } catch { + // telemetry dir doesn't exist — correct + } + }); +}); diff --git a/integ-tests/recommendation.test.ts b/integ-tests/recommendation.test.ts new file mode 100644 index 000000000..dc3037a3e --- /dev/null +++ b/integ-tests/recommendation.test.ts @@ -0,0 +1,290 @@ +import { type TestProject, createTestProject, parseJsonOutput, runCLI } from '../src/test-utils/index.js'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('integration: run recommendation CLI validation', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + describe('required flags', () => { + it('requires --runtime', async () => { + const result = await runCLI( + ['run', 'recommendation', '--evaluator', 'Builtin.Faithfulness', '--inline', 'test prompt', '--json'], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('--runtime'); + }); + + it('requires --evaluator for system-prompt type', async () => { + const result = await runCLI( + ['run', 'recommendation', '--runtime', project.agentName, '--inline', 'test prompt', '--json'], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('--evaluator'); + }); + + it('rejects invalid --type', async () => { + const result = await runCLI( + [ + 'run', + 'recommendation', + '--type', + 'invalid-type', + '--runtime', + project.agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--inline', + 'test prompt', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('--type'); + }); + }); + + describe('system-prompt recommendation input validation', () => { + it('fails when agent not deployed (inline input)', async () => { + const result = await runCLI( + [ + 'run', + 'recommendation', + '--runtime', + project.agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--inline', + 'You are a helpful assistant.', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('deployed'); + }); + + it('fails when agent not deployed (file input)', async () => { + const promptFile = join(project.projectPath, 'system-prompt.txt'); + await writeFile(promptFile, 'You are a helpful assistant for testing.'); + + const result = await runCLI( + [ + 'run', + 'recommendation', + '--runtime', + project.agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--prompt-file', + promptFile, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('deployed'); + }); + + it('fails with non-existent prompt file', async () => { + const result = await runCLI( + [ + 'run', + 'recommendation', + '--runtime', + project.agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--prompt-file', + '/tmp/nonexistent-prompt-file-xyz.txt', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + }); + }); + + describe('tool-description recommendation input validation', () => { + it('fails when agent not deployed (tool-description type with --tools)', async () => { + const result = await runCLI( + [ + 'run', + 'recommendation', + '--type', + 'tool-description', + '--runtime', + project.agentName, + '--tools', + 'search:Searches the web for information', + '--tools', + 'calculator:Performs math calculations', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('deployed'); + }); + }); + + describe('config bundle source validation', () => { + it('fails when bundle not found in deployed state', async () => { + const result = await runCLI( + [ + 'run', + 'recommendation', + '--runtime', + project.agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--bundle-name', + 'NonExistentBundle', + '--bundle-version', + 'v1', + '--system-prompt-json-path', + 'systemPrompt', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + // Fails at agent resolution (not deployed) before bundle resolution + expect(json.error).toContain('deployed'); + }); + }); + + describe('spans file validation', () => { + it('fails when spans file does not exist', async () => { + const result = await runCLI( + [ + 'run', + 'recommendation', + '--runtime', + project.agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--inline', + 'You are a helpful assistant.', + '--spans-file', + '/tmp/nonexistent-spans-xyz.json', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + }); + + it('fails when spans file contains invalid JSON', async () => { + const spansFile = join(project.projectPath, 'bad-spans.json'); + await writeFile(spansFile, 'not valid json'); + + const result = await runCLI( + [ + 'run', + 'recommendation', + '--runtime', + project.agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--inline', + 'You are a helpful assistant.', + '--spans-file', + spansFile, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + }); + }); + + describe('lookback and session options', () => { + it('accepts --lookback flag (fails at deploy check, not parsing)', async () => { + const result = await runCLI( + [ + 'run', + 'recommendation', + '--runtime', + project.agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--inline', + 'You are a helpful assistant.', + '--lookback', + '14', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.error).toContain('deployed'); + }); + + it('accepts --session-id flag (fails at deploy check, not parsing)', async () => { + const result = await runCLI( + [ + 'run', + 'recommendation', + '--runtime', + project.agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--inline', + 'You are a helpful assistant.', + '--session-id', + 'sess-001', + 'sess-002', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.error).toContain('deployed'); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 0bdaa8bd7..a1bfba552 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.11.0", + "version": "0.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.11.0", + "version": "0.13.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -24,7 +24,7 @@ "@aws-sdk/client-sts": "^3.893.0", "@aws-sdk/client-xray": "^3.1003.0", "@aws-sdk/credential-providers": "^3.893.0", - "@aws/agent-inspector": "0.2.1", + "@aws/agent-inspector": "0.3.0", "@commander-js/extra-typings": "^14.0.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", @@ -242,9 +242,9 @@ } }, "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "53.18.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.18.0.tgz", - "integrity": "sha512-/fa6rOpokkfa5tVIdhsaexQq5MVVTSsZSD1Tu45YcrdyGRusGrM9RlPMCPrwvMS1UfdVFBhcgO9dl9ODWAWOeQ==", + "version": "53.19.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.19.0.tgz", + "integrity": "sha512-pHtEeuiPwYu4vWJhqTqBCvOWy2w4BjYFW5W8L5lH5AzhKLLG92o+Ck0oLcKUkB0Ucl4ff49WL9K7sa7g35ionw==", "bundleDependencies": [ "jsonschema", "semver" @@ -377,14 +377,14 @@ } }, "node_modules/@aws-cdk/toolkit-lib": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/toolkit-lib/-/toolkit-lib-1.24.0.tgz", - "integrity": "sha512-tgtH0CJ8/N/CpT1/ebOBfUpxdAMSRsP9LTAjWfa+E0clX4Vuvx0w1J1bGYwtvKY9nQUbFIO4QfgNEHz8hVlMUA==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/toolkit-lib/-/toolkit-lib-1.25.0.tgz", + "integrity": "sha512-yTxQLcI7T8wfUAhWw8vO7dG//x0WL786AA5u4i/yRtSVSJd+0frHX6gD+njSv41OaNLhwP6EVYkFz9wTyCt64A==", "license": "Apache-2.0", "dependencies": { "@aws-cdk/cdk-assets-lib": "^1", "@aws-cdk/cloud-assembly-api": "2.2.2", - "@aws-cdk/cloud-assembly-schema": ">=53.18.0", + "@aws-cdk/cloud-assembly-schema": ">=53.19.0", "@aws-cdk/cloudformation-diff": "^2", "@aws-cdk/cx-api": "^2", "@aws-sdk/client-appsync": "^3", @@ -415,7 +415,7 @@ "@smithy/util-retry": "^4", "@smithy/util-waiter": "^4", "archiver": "^7.0.1", - "cdk-from-cfn": "^0.295.0", + "cdk-from-cfn": "^0.297.0", "chalk": "^4", "chokidar": "^4", "fast-deep-equal": "^3.1.3", @@ -687,24 +687,24 @@ } }, "node_modules/@aws-sdk/client-application-signals": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-application-signals/-/client-application-signals-3.1037.0.tgz", - "integrity": "sha512-xnGVyIWU1SXNSnnARvU3U3sic0QWH0wek/X3WpXFCpOm0NBzbTablWiAszNDU9RCvg9KUDm6Wdp0T4jnodXhEg==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-application-signals/-/client-application-signals-3.1038.0.tgz", + "integrity": "sha512-kUBaXESTxZO+JiMQIFU/Wv0Lop+KtuJP0xOv00dL/4a0RdcqNdAwbH7RnxXaL5z7toDLCfg9j93f0IQEdBLbXA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -712,7 +712,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -728,7 +728,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -788,25 +788,25 @@ } }, "node_modules/@aws-sdk/client-bedrock": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1037.0.tgz", - "integrity": "sha512-XGuJ86vuuEsqp0Gq8fMCSMd/VNCwqTvKwFT99SU2OOLyNp31ChZ+LdIckJZl/A3jpUyZYpXjn7IxP/N/6UFiZA==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1038.0.tgz", + "integrity": "sha512-WY99Vodg7V4hxLQn7HOLawXHeVYv8Ys16Xx3CPpu8L7+1spvO/i4uykzTXH6GkojdAqNO2CSclhk31lb85nSWg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/token-providers": "3.1037.0", + "@aws-sdk/token-providers": "3.1038.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -814,7 +814,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -830,7 +830,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -839,24 +839,24 @@ } }, "node_modules/@aws-sdk/client-bedrock-agent": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agent/-/client-bedrock-agent-3.1037.0.tgz", - "integrity": "sha512-8Uc3zdwfxmjMrXb4qP69bByL054R+jWNaW3/Hq93E1jnag8vZz8YhJlz1x6jr1oXOoOLVErc1L/g8x0sMFZuRA==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agent/-/client-bedrock-agent-3.1038.0.tgz", + "integrity": "sha512-cwOkwVhYs+GeW7SzNS/pmgkHZ/SAyv4wIkz62vew6sM6R+XgqqBbtEqDrf+FSsprFtx75edbduRg8boJt0veSg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -864,7 +864,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -880,7 +880,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -889,24 +889,24 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1037.0.tgz", - "integrity": "sha512-8WmZulMmFnCWFuX2rDBoZdebCMmmrAi1VABsLgm4O73w3+s7tcON1YgspG9gTevuVRtOVdk1B6TLw2Mo8NBHSQ==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1038.0.tgz", + "integrity": "sha512-HHsWj3AWsBnvihV0oUolOlnPxXum75kTdQHuYMX6kko4ek2iQWOe1WxUQG4TwOS7HUivU3Zf6CNti/K6wEkc7A==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", @@ -917,7 +917,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -933,7 +933,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -943,24 +943,24 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore-control": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1037.0.tgz", - "integrity": "sha512-tMfeMgohJ6L9ARRSdK8O7lbdYIggeRXtuRQFS+kISZTlvw+L4TjhUZ7TT5yIBO0UVkunoWsRQIH4VP4pgiVqQA==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1038.0.tgz", + "integrity": "sha512-S+BtFwV7C5yUwjMZwExp0hAmdMCXLA2iG6KPvoS4zv8T2bMk1itrWfE4opqSZ1lgwDmnazcG7mmfAAUBFXvKXw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -968,7 +968,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -984,9 +984,9 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.16", + "@smithy/util-waiter": "^4.3.0", "tslib": "^2.6.2" }, "engines": { @@ -994,28 +994,28 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1037.0.tgz", - "integrity": "sha512-Evla4DUdBf1pQpQa7pbfquj7jRaRktkI0qGoWBJBXWB9wQISzJ8OEI4sHugk/W6SF47C7hMP/o3Z/XBrfnejCw==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1038.0.tgz", + "integrity": "sha512-oGiqs9v9WzPOdv7PDdm9iPibHgrbDvCDyNg43wFZn2PiiEUisFM+xUP2CRMsj41SmwZPhohmZkXiUu1+MghbAQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/middleware-websocket": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/token-providers": "3.1037.0", + "@aws-sdk/token-providers": "3.1038.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", @@ -1026,7 +1026,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1042,7 +1042,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -1051,42 +1051,6 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1037.0.tgz", - "integrity": "sha512-csxa484KboWLs3f8jFQ5v9RwH8FVf0fQ+SO3GSXyu4Jtinhh4qXmOWLSVX30RBpB933dZaKGHGEXzEEY88NqRw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/nested-clients": "^3.997.3", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock/node_modules/@aws-sdk/token-providers": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1037.0.tgz", - "integrity": "sha512-csxa484KboWLs3f8jFQ5v9RwH8FVf0fQ+SO3GSXyu4Jtinhh4qXmOWLSVX30RBpB933dZaKGHGEXzEEY88NqRw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/nested-clients": "^3.997.3", - "@aws-sdk/types": "^3.973.8", - "@smithy/property-provider": "^4.2.14", - "@smithy/shared-ini-file-loader": "^4.4.9", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/client-cloudcontrol": { "version": "3.1036.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudcontrol/-/client-cloudcontrol-3.1036.0.tgz", @@ -1139,24 +1103,24 @@ } }, "node_modules/@aws-sdk/client-cloudformation": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1037.0.tgz", - "integrity": "sha512-nLSwtmayv7tjjp6t8Lc20xZCeA+XJ5UzXvauQCnO3aRZVAxrgarQntZjS+eWlRYGRqLBjXSre4xL7XwUlObb2A==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1038.0.tgz", + "integrity": "sha512-5c8u4magoWj8uAhiKoBY0/FXqCRqw3g05RXiQzUdNbeZDhfnv2b/r0aITVLlVEW/zfQUGi/WJfaV4vuffp6k1A==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -1164,7 +1128,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1180,9 +1144,9 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.16", + "@smithy/util-waiter": "^4.3.0", "tslib": "^2.6.2" }, "engines": { @@ -1190,24 +1154,24 @@ } }, "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1037.0.tgz", - "integrity": "sha512-W0TRDfyBikNR+DzOTBgBLT4TqVHCAasqx2Xu4G4PfTRCansUtEJRydq0CEVOpHlMfme4Va89O/r9sp/VoDsKRg==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1038.0.tgz", + "integrity": "sha512-OfdF+xZiiHWR5JL9rsc8jjlk1fNxnX2uS2iWWBM9vncVSXdhPAMb/rFNeHLkIx9IfmyiNi7kXUTAKgIUkVN0+w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", @@ -1218,7 +1182,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1234,7 +1198,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1293,24 +1257,24 @@ } }, "node_modules/@aws-sdk/client-cognito-identity": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1037.0.tgz", - "integrity": "sha512-/BQAyz98JRQFg3E8de3fGGydIYnsFRd6Cla4+zkviOe641fLCG0ZkPIk9D22HSi8qy9XKx+zk6ed2PcLO8uuPw==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1038.0.tgz", + "integrity": "sha512-tTSXUZXzydM0VUoxcrM4YrhhQfFgepfpbRLEq460650rFAC8NsGhGQ6Ixo7UPV6TKEyI/jQcCnQVi4RVM4SkAg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -1318,7 +1282,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1334,7 +1298,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1343,25 +1307,25 @@ } }, "node_modules/@aws-sdk/client-cognito-identity-provider": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1037.0.tgz", - "integrity": "sha512-w0HuaMNtzcj6bErBX8/TVGbOz0a8JNCzPHLMq2u/ll4uuxl9Xut0njuy7vyY0/pYCuNE+no4uq+yHwn8U3ptgw==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1038.0.tgz", + "integrity": "sha512-E3/2sei5wiUvS+ZdlvQ93SXU2C9zdqq1mJqxik6B8GHtSnP2T4J97Hdu46FM6GK/HZJ+iFZFeIlM0B+P3uH2QQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -1369,7 +1333,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1385,7 +1349,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1755,24 +1719,24 @@ } }, "node_modules/@aws-sdk/client-resource-groups-tagging-api": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-resource-groups-tagging-api/-/client-resource-groups-tagging-api-3.1037.0.tgz", - "integrity": "sha512-1+sv7vbSSRqVMBTvgFPFopXZ/SGdz3jP9aIHM8eOIuuZGYSmuqiXkNd7Ag5yxIQqEDO8sASevCqscEqN0f4OUw==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-resource-groups-tagging-api/-/client-resource-groups-tagging-api-3.1038.0.tgz", + "integrity": "sha512-jEjVlhcAb4j658XKfxBPyyrbXJKmfg2bR3Xokqy5lhvZA80t6p47jeQ4s5yXIsaNzxnefJ8SuqrVft2KBHbDrQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -1780,7 +1744,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1796,7 +1760,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1857,32 +1821,32 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1037.0.tgz", - "integrity": "sha512-DBmA1jAW8ST6C4srBxeL1/RLIir/d8WOm4s4mi59mGp6mBktHM59Kwb7GuURaCO60cotuce5zr0sKpMLPcBQyA==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1038.0.tgz", + "integrity": "sha512-k60qm50bWkaqNfCJe1z28WaqgpztE0wbWVMZw6ZJcTOGfrWFhsJeLCEqtkH8w00iEozKx9GQwdQXz4G0sMGdKA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-bucket-endpoint": "^3.972.10", "@aws-sdk/middleware-expect-continue": "^3.972.10", - "@aws-sdk/middleware-flexible-checksums": "^3.974.13", + "@aws-sdk/middleware-flexible-checksums": "^3.974.14", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-location-constraint": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-sdk-s3": "^3.972.34", + "@aws-sdk/middleware-sdk-s3": "^3.972.35", "@aws-sdk/middleware-ssec": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.22", + "@aws-sdk/signature-v4-multi-region": "^3.996.23", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", @@ -1896,7 +1860,7 @@ "@smithy/md5-js": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1912,10 +1876,10 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.16", + "@smithy/util-waiter": "^4.3.0", "tslib": "^2.6.2" }, "engines": { @@ -2074,25 +2038,25 @@ } }, "node_modules/@aws-sdk/client-sts": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1037.0.tgz", - "integrity": "sha512-Ye+BEvy1Fd/JtqfF1T9PiodIU52/Cd9sP4oBLnj8QQEyYRUcYG1OQ2xIFXF/gzAAMjfVN8HqGJo9LxdmScxZAQ==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1038.0.tgz", + "integrity": "sha512-lboBAXDIr+ot5a357mQgaAwgMMYZW7EwO216LTASUHV3UN4YgqskrEcwsDV9765KH9wUDGxFt8rClS4ixaOgiA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.22", + "@aws-sdk/signature-v4-multi-region": "^3.996.23", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -2100,7 +2064,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -2116,7 +2080,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2125,24 +2089,24 @@ } }, "node_modules/@aws-sdk/client-xray": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-xray/-/client-xray-3.1037.0.tgz", - "integrity": "sha512-cnic610qpFrbR3gNw0pFi6EFrkDhDJOHlOnQzpdjBQ38O3QXwkON6Kco8IyTs7TlyOr7HRmHnBiEYVDZrLVC0w==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-xray/-/client-xray-3.1038.0.tgz", + "integrity": "sha512-wPO92YHLQDbaHog82RFCmrGmMl5bdF6SOcejBr/RGcORsL4t6RXvaVP9sF73txgOTDRgUQh/EO1uy/JmTC9Dgg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -2150,7 +2114,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -2166,7 +2130,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2175,13 +2139,13 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.5.tgz", - "integrity": "sha512-lMPlYlYfQdNZhlkJgnkmESwrY+hNh3PljmZ+37oAqLNdJ6rnILAwFSyc6B3bJeDOtMORNnMQIej0aTRuOlDyhQ==", + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.6.tgz", + "integrity": "sha512-8Vu7zGxu+39ChR/s5J7nXBw3a2kMHAi0OfKT8ohgTVjX0qYed/8mIfdBb638oBmKrWCwwKjYAM5J/4gMJ8nAJA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.19", + "@aws-sdk/xml-builder": "^3.972.20", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", @@ -2191,7 +2155,7 @@ "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2213,12 +2177,12 @@ } }, "node_modules/@aws-sdk/credential-provider-cognito-identity": { - "version": "3.972.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.28.tgz", - "integrity": "sha512-UXhc4FfxbfNaIqycDnIZ+W8CMAoCtcJJfZkq+cWSUwQRN0V0d0uAoN2qCFyKZip8inlHeKJmNQsPliKKcElP8Q==", + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.29.tgz", + "integrity": "sha512-fklwtMw+9+1TRNa7KOCaaE9P9ubN6PdKCVlviX/vPRNtnMGIivAFrWcYsAcyw+sHPPioiSCSOHKKAhtOkO6IGg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/nested-clients": "^3.997.4", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", @@ -2229,12 +2193,12 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.31.tgz", - "integrity": "sha512-X/yGB73LmDW/6MdDJGCDzZBUXnM3ys4vs9l+5ZTJmiEswDdP1OjeoAFlFjVGS9o4KB2wZWQ9KOfdVNSSK6Ep3w==", + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.32.tgz", + "integrity": "sha512-7vA4GHg8NSmQxquJHSBcSM3RgB4ZaaRi6u4+zGFKOmOH6aqlgr2Sda46clkZDYzlirgfY96w15Zj0jh6PT48ng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.5", + "@aws-sdk/core": "^3.974.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", @@ -2245,12 +2209,12 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.33", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.33.tgz", - "integrity": "sha512-c0ZF+lwoWVvX5iCaGKL5T/4DnIw88CGqxA0BcBs3U86mIp5EZYPVg+KSPkMXOyokmADvNewiMUfSG2uFwjRp0g==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.34.tgz", + "integrity": "sha512-vBrhWujFCLp1u8ptJRWYlipMutzPptb8pDQ00rKVH9q67T7rGd3VTWIj63aKrlLuY6qSsw1Rt5F/D/7wnNgryA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.5", + "@aws-sdk/core": "^3.974.6", "@aws-sdk/types": "^3.973.8", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", @@ -2266,19 +2230,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.35.tgz", - "integrity": "sha512-jsU4u/cRkKFLKQS0k918FQ27fzXLG5ENiLWQMYE6581zLeI2hWh04ptlrvZMB3wJT/5d+vSzJk74X1CMFr4y8Q==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.36.tgz", + "integrity": "sha512-FBHyCmV8EB0gUvh1d+CZm87zt2PrdC7OyWexLRoH3I5zWSOUGa+9t58Y5jbxRfwUp3AWpHAFvKY6YzgR845sVA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-env": "^3.972.31", - "@aws-sdk/credential-provider-http": "^3.972.33", - "@aws-sdk/credential-provider-login": "^3.972.35", - "@aws-sdk/credential-provider-process": "^3.972.31", - "@aws-sdk/credential-provider-sso": "^3.972.35", - "@aws-sdk/credential-provider-web-identity": "^3.972.35", - "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-env": "^3.972.32", + "@aws-sdk/credential-provider-http": "^3.972.34", + "@aws-sdk/credential-provider-login": "^3.972.36", + "@aws-sdk/credential-provider-process": "^3.972.32", + "@aws-sdk/credential-provider-sso": "^3.972.36", + "@aws-sdk/credential-provider-web-identity": "^3.972.36", + "@aws-sdk/nested-clients": "^3.997.4", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", @@ -2291,13 +2255,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.35.tgz", - "integrity": "sha512-5oa3j0cA50jPqgNhZ9XdJVopuzUf1klRb28/2MfLYWWiPi9DRVvbrBWT+DidbHTT36520VuXZJahQwR+YgSjrg==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.36.tgz", + "integrity": "sha512-IFap01lJKxQc0C/OHmZwZQr/cKq0DhrcmKedRrdnnl42D+P0SImnnnWQjv07uIPqpEdtqmkPXb9TiPYTU+prxQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", @@ -2310,17 +2274,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.36.tgz", - "integrity": "sha512-4nT2T8Z7vH8KE9EdjEsuIlHpZSlcaK2PrKbQBjuUGU46BCCzF3WvP0u0Uiosni3Ykmmn4rWLVawoOCLotUtCbg==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.37.tgz", + "integrity": "sha512-/WFixFAAiw8WpmjZcI0l4t3DerXLmVinOIfuotmRZnu2qmsFPoqqmstASz0z8bi1pGdFXzeLzf6bwucM3mZcUQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.31", - "@aws-sdk/credential-provider-http": "^3.972.33", - "@aws-sdk/credential-provider-ini": "^3.972.35", - "@aws-sdk/credential-provider-process": "^3.972.31", - "@aws-sdk/credential-provider-sso": "^3.972.35", - "@aws-sdk/credential-provider-web-identity": "^3.972.35", + "@aws-sdk/credential-provider-env": "^3.972.32", + "@aws-sdk/credential-provider-http": "^3.972.34", + "@aws-sdk/credential-provider-ini": "^3.972.36", + "@aws-sdk/credential-provider-process": "^3.972.32", + "@aws-sdk/credential-provider-sso": "^3.972.36", + "@aws-sdk/credential-provider-web-identity": "^3.972.36", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", @@ -2333,12 +2297,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.31.tgz", - "integrity": "sha512-eKeT4MXumpBJsrDLCYcSzIkFPVTFn/es7It2oogp2OhU/ic7P/+xzFpQx9ZhwtXS57Mc5S42BPWi7lHmvs/nYg==", + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.32.tgz", + "integrity": "sha512-uZp4tlGbpczV8QxmtIwOpSkcyGtBRR8/T4BAumRKfAt1nwCig3FSCZvrKl6ARDIDVRYn5p2oRcAsfFR01EgMGA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.5", + "@aws-sdk/core": "^3.974.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2350,14 +2314,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.35.tgz", - "integrity": "sha512-bCuBdfnj0KGDMdLp6utMTLiJcFN2ek9EgZinxQZZSc3FxjJ/HSqeqab2cjbnoNfy8RM6suDCsRkmVY1izp9I+A==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.36.tgz", + "integrity": "sha512-DsLr0UHMyKzRJKe2bjlwU8q1cfoXg8TIJKV/xwvnalAemiZLOZunFzj/whGnFDZIBVLdnbLiwv5SvRf1+CSwkg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/nested-clients": "^3.997.3", - "@aws-sdk/token-providers": "3.1036.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/token-providers": "3.1038.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2369,13 +2333,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.35.tgz", - "integrity": "sha512-swW6Bwvl8lanyEMtZOWE/oR6yqcRQH4HTQZUVsnDVgoXvRjRywpYpLv2BWwjUFyjPrqsdX6FeTkf4tMSe/qFTQ==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.36.tgz", + "integrity": "sha512-uzrURO7frJhHQVVNR5zBJcCYeMYflmXcWBK1+MiBym2Dfjh6nXATrMixrmGZi+97Q7ETZ+y/4lUwAy0Nfnznjw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2387,23 +2351,23 @@ } }, "node_modules/@aws-sdk/credential-providers": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1037.0.tgz", - "integrity": "sha512-TPPoQzfNkWltNgjJn3RRY1S8VXffDvv49xGGs9K0DrYS9LZCLLsoHmSmShx9HQusPc/4Oz23rfRWTolCU19PdQ==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1038.0.tgz", + "integrity": "sha512-+B9BuRVPPKF0Q6msVS4vUGOsL4eUg7XYogikp56rUEQVoUVxn5ONyWlnNzsDMTv+BwuBgFo5N7gRZtEToAnSgg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-cognito-identity": "3.1037.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-cognito-identity": "^3.972.28", - "@aws-sdk/credential-provider-env": "^3.972.31", - "@aws-sdk/credential-provider-http": "^3.972.33", - "@aws-sdk/credential-provider-ini": "^3.972.35", - "@aws-sdk/credential-provider-login": "^3.972.35", - "@aws-sdk/credential-provider-node": "^3.972.36", - "@aws-sdk/credential-provider-process": "^3.972.31", - "@aws-sdk/credential-provider-sso": "^3.972.35", - "@aws-sdk/credential-provider-web-identity": "^3.972.35", - "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/client-cognito-identity": "3.1038.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.29", + "@aws-sdk/credential-provider-env": "^3.972.32", + "@aws-sdk/credential-provider-http": "^3.972.34", + "@aws-sdk/credential-provider-ini": "^3.972.36", + "@aws-sdk/credential-provider-login": "^3.972.36", + "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/credential-provider-process": "^3.972.32", + "@aws-sdk/credential-provider-sso": "^3.972.36", + "@aws-sdk/credential-provider-web-identity": "^3.972.36", + "@aws-sdk/nested-clients": "^3.997.4", "@aws-sdk/types": "^3.973.8", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", @@ -2521,15 +2485,15 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.13.tgz", - "integrity": "sha512-b6QUe2hQX9XsnCzp6mtzVaERhganDKeb8lmGL6pVhr7rRVH9S9keDFW7uKytuuqmcY5943FixoGqn/QL+sbUBA==", + "version": "3.974.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.14.tgz", + "integrity": "sha512-mhTO3amGzYv/DQNbbqZo6UkHquBHlEEVRZwXmjeRqLmy1l9z3xCiFzglPL7n9JpVc2DZc9kjaraAn3JQrueZbw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.974.5", + "@aws-sdk/core": "^3.974.6", "@aws-sdk/crc64-nvme": "^3.972.7", "@aws-sdk/types": "^3.973.8", "@smithy/is-array-buffer": "^4.2.2", @@ -2638,12 +2602,12 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.34.tgz", - "integrity": "sha512-/UL96JKjsjdodcRRMKl99tLQvK6Oi9ptLC9iU1yiTF/ruaDX0mtBBtnLNZDxIZRJOCVOtB49ed1YaTadqygk8Q==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.35.tgz", + "integrity": "sha512-lLppaNTAz+wNgLdi4FtHzrlwrGF0ODTnBWHBaFg85SKs0eJ+M+tP5ifrA8f/0lNd+Ak3MC1NGC6RavV3ny4HTg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.5", + "@aws-sdk/core": "^3.974.6", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.17", @@ -2677,18 +2641,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.35.tgz", - "integrity": "sha512-hOFWNOjVmOocpRlrU04nYxjMOeoe0Obu5AXEuhB8zblMCPl3cG1hdluQCZERRKFyhMQjwZnDbhSHjoMUjetFGw==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.36.tgz", + "integrity": "sha512-O2beToxguBvrZFFZ+fFgPbbae8MvyIBjQ6lImee4APHEXXNAD5ZJ2ayLF1mb7rsKw86TM81y5czg82bZncjSjg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.5", + "@aws-sdk/core": "^3.974.6", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "tslib": "^2.6.2" }, "engines": { @@ -2719,24 +2683,24 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.3.tgz", - "integrity": "sha512-SivE6GP228IVgfsrr2c/vqTg95X0Qj39Yw4uIrcddpkUzIltNMoNOR62leHOLhODfjv9K8X2mPTwS69A5kT0nQ==", + "version": "3.997.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.4.tgz", + "integrity": "sha512-4Sf+WY1lMJzXlw5MiyCMe/UzdILCwvuaHThbqMXS6dfh9gZy3No360I42RXquOI/ULUOhWy2HCyU0Fp20fQGPQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", + "@aws-sdk/core": "^3.974.6", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.22", + "@aws-sdk/signature-v4-multi-region": "^3.996.23", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", + "@aws-sdk/util-user-agent-node": "^3.973.22", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -2744,7 +2708,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", + "@smithy/middleware-retry": "^4.5.6", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -2760,7 +2724,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.5", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2785,12 +2749,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.22.tgz", - "integrity": "sha512-/rXhMXteD+BqhFd0nYprAgcZ/KtU+963uftPqd3tiFcFfooHZINXUGtOmo2SQjRVauCTNqIEzkwuSETdZFqTTA==", + "version": "3.996.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.23.tgz", + "integrity": "sha512-wBbys3Y53Ikly556vyADurKpYQHXS7Jjaskbz+Ga9PZCz7PB/9f3VdKbDlz7dqIzn+xwz7L/a6TR4iXcOi8IRw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.34", + "@aws-sdk/middleware-sdk-s3": "^3.972.35", "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", @@ -2802,13 +2766,13 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1036.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1036.0.tgz", - "integrity": "sha512-aNSJ6jjDYayxN9ZA1JpycVScX93Lx03kKZ1EXt3DGOTahcWVLJj3oLAlop0xKP+vP2Ga2t49p1tEaMkTbCCaZA==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1038.0.tgz", + "integrity": "sha512-Qniru+9oGGb/HNK/gGZWbV3jsD0k71ngE7qMQ/x6gYNYLd2EOwHCS6E2E6jfkaqO4i0d+nNKmfRy8bNcshKdGQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2900,12 +2864,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.21.tgz", - "integrity": "sha512-Av4UHTcAWgdvbN0IP9pbtf4Qa1+6LtJqQdZWj5pLn5J67w0pnJJAZZ+7JPPcj2KN3378zD2JDM9DwJKEyvyMTQ==", + "version": "3.973.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.22.tgz", + "integrity": "sha512-YTYqTmOUrwbm1h99Ee4y/mVYpFRl0oSO/amtP5cc1BZZWdaAVWs9zj3TkyRHWvR9aI/ZS8m3mS6awXtYUlWyaw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.35", + "@aws-sdk/middleware-user-agent": "^3.972.36", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", @@ -2939,9 +2903,9 @@ } }, "node_modules/@aws/agent-inspector": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@aws/agent-inspector/-/agent-inspector-0.2.1.tgz", - "integrity": "sha512-kyL6RBcTj1hYIchtrHDlDyeqm2viVYMBxhZKVn8wJn058YhI52GIDuUFlKD1avd57X+LJKlHr5VcKvBZp7Sg6A==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/agent-inspector/-/agent-inspector-0.3.0.tgz", + "integrity": "sha512-xD7QPr1WWkT9QWRWo6e9kq8kYxJLQ8egGscgSZ6jCyW3wNV5fcQ6THcAR/71hxxMFF2aleNUc3D8MoqgiS4DVw==", "license": "Apache-2.0", "dependencies": { "@ag-ui/core": "^0.0.52", @@ -3943,75 +3907,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -4240,6 +4135,22 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-logs": { "version": "0.214.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", @@ -4258,6 +4169,22 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", + "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", @@ -4324,6 +4251,22 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/resources": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-logs": { "version": "0.214.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", @@ -4342,6 +4285,22 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", + "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-trace-base": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", @@ -4428,12 +4387,12 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.0.tgz", + "integrity": "sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.7.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4443,6 +4402,21 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", + "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/sdk-logs": { "version": "0.213.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz", @@ -4493,13 +4467,13 @@ } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", - "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.0.tgz", + "integrity": "sha512-Vd7h95av/LYRsAVN7wbprvvJnHkq7swMXAo7Uad0Uxf9jl6NSReLa0JNivrcc5BVIx/vl2t+cgdVQQbnVhsR9w==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1" + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4508,6 +4482,21 @@ "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", + "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/sdk-trace-base": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", @@ -4575,16 +4564,6 @@ "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", @@ -4948,28 +4927,28 @@ "license": "MIT" }, "node_modules/@secretlint/config-creator": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-12.2.0.tgz", - "integrity": "sha512-enoydCMrJ8rmrM09qxDBd2XU1V3u9N9CfjRyUbYh3+m74G17u2PCTnlAw5UyeobewCb06d4Dym5t5ybCabATyA==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-12.3.1.tgz", + "integrity": "sha512-CCRvPfrQLt2fPg3eWTIDGXNcVFQd6ZnvQCZ5lzclV9OF7iRqXQ4l5lfGO8NS68tIZx7YvBKhcO8/eVxdqm89HA==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "12.2.0" + "@secretlint/types": "12.3.1" }, "engines": { "node": ">=22.0.0" } }, "node_modules/@secretlint/config-loader": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-12.2.0.tgz", - "integrity": "sha512-f7B9o6YF1jhTtd0ccJywcliCWkP02eYNM4efmua77AuztQTkXLVsw6eECXGAfZ9vh9uPHAK87Km6X4ta5hhtlA==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-12.3.1.tgz", + "integrity": "sha512-PNrxz8tnAU/y5PmfOtKfVb+zEA3I+1iZqP1f9fXvIBtauBKs0h0Y+Cmvj0gG1a34kxaD1aQvFh8qHEhRV05gWg==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/profiler": "12.2.0", - "@secretlint/resolver": "12.2.0", - "@secretlint/types": "12.2.0", + "@secretlint/profiler": "12.3.1", + "@secretlint/resolver": "12.3.1", + "@secretlint/types": "12.3.1", "ajv": "^8.18.0", "debug": "^4.4.3", "rc-config-loader": "^4.1.4" @@ -4979,14 +4958,14 @@ } }, "node_modules/@secretlint/core": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-12.2.0.tgz", - "integrity": "sha512-ZT4irO8fPUg2810kcnfNQZ+AHIIYLFKyEqR91aSDl3g/RFOOLC66CAzGmMA1OuMc+sx9XE9TnM/IpLmLVvUSnA==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-12.3.1.tgz", + "integrity": "sha512-ulcfARo1TANr8tWzDO/5cFxSNEEfRzgW6YPHYUijgpH3iYfwtUhWEU/r/BiFGl2PNaGzzVE1N9A6nZ74xbYvUQ==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/profiler": "12.2.0", - "@secretlint/types": "12.2.0", + "@secretlint/profiler": "12.3.1", + "@secretlint/types": "12.3.1", "debug": "^4.4.3", "structured-source": "^4.0.0" }, @@ -4995,14 +4974,14 @@ } }, "node_modules/@secretlint/formatter": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-12.2.0.tgz", - "integrity": "sha512-1KDSx4NgKObi8OQoPjBaGa41/sv9ZIrEMa94kQ3PhhVTPONP4N618W2c1CBVMuSNvRHilsKjXWZlKJKcIF4FlQ==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-12.3.1.tgz", + "integrity": "sha512-SXpTiRzuuFNbHa59zk0eUxFOB/LYxnuHSfqq7zU9lIt0z5rox6NrnN9WWkoQai2V9s7n3VqUVUpZqnhxQ2Jzpw==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/resolver": "12.2.0", - "@secretlint/types": "12.2.0", + "@secretlint/resolver": "12.3.1", + "@secretlint/types": "12.3.1", "@textlint/linter-formatter": "^15.5.4", "@textlint/module-interop": "^15.5.4", "@textlint/types": "^15.5.4", @@ -5031,18 +5010,18 @@ } }, "node_modules/@secretlint/node": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-12.2.0.tgz", - "integrity": "sha512-7hZxi49l2pkGjCT/BQf+ElKqFcbxooH9JslCThRfAMElyL3KGo14HhGfFFyWhhTLH2enAds1nKXczhWBI3otIQ==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-12.3.1.tgz", + "integrity": "sha512-1T08nqwWIJqSRrfkebk4Op5MwYgNnB6gwjv9v+X+V+HEIeG1GB/EgH8CJa8jK4uYdhUuaKyXpu36FIbjNa1wqA==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-loader": "12.2.0", - "@secretlint/core": "12.2.0", - "@secretlint/formatter": "12.2.0", - "@secretlint/profiler": "12.2.0", - "@secretlint/source-creator": "12.2.0", - "@secretlint/types": "12.2.0", + "@secretlint/config-loader": "12.3.1", + "@secretlint/core": "12.3.1", + "@secretlint/formatter": "12.3.1", + "@secretlint/profiler": "12.3.1", + "@secretlint/source-creator": "12.3.1", + "@secretlint/types": "12.3.1", "debug": "^4.4.3", "p-map": "^7.0.4" }, @@ -5051,23 +5030,23 @@ } }, "node_modules/@secretlint/profiler": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-12.2.0.tgz", - "integrity": "sha512-tB1NhUbCWH+32wSx6xE+Uj7nTUkidYEyW6B6pdGxsiZSM4SGz+FuKpr9OcylGsEphkkz1cQA3P9CjwCHcQqrnw==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-12.3.1.tgz", + "integrity": "sha512-lztyqJPTfkY0Ze9P7vNs3zm7p2Wq1+4ilFXVrxin0sDyFVXpkt+0+vsKsmdx9yBHabxrLDZgxa7fIsfV721cLw==", "dev": true, "license": "MIT" }, "node_modules/@secretlint/resolver": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-12.2.0.tgz", - "integrity": "sha512-k3Mq4zeLpJtvBoEggEYstWhEiD23tL8qHbz/eYN+yQaQ2tItebIMd34qFX1jjeooiZdp/OuNWZA5JeyTw+SXcQ==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-12.3.1.tgz", + "integrity": "sha512-/QwcX5azKRdz9mBIbTBUsqp+cmWQZYGNdOHLbsMOBTLXa7KoEBffhmeaMSc0kNSrdgbgfu/7j+qeeaF4QwJf3A==", "dev": true, "license": "MIT" }, "node_modules/@secretlint/secretlint-rule-preset-recommend": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-12.2.0.tgz", - "integrity": "sha512-n4qknL6vYRelmyrAyV/Z8I85c6jS6yF/ZxpgcqebjJuECIiel8OT2wIVIq9vk0MwlQN35skaQu0KvfM8uuGeyA==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-12.3.1.tgz", + "integrity": "sha512-w9x9rIP1+qhV0k9k15aSIBo+6NrM4npAYIoNs7UmKIxuQSA6b91rhWpdMxgCv2/EQtym+gpuyN7rTSvG5wlYlw==", "dev": true, "license": "MIT", "engines": { @@ -5075,13 +5054,13 @@ } }, "node_modules/@secretlint/source-creator": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-12.2.0.tgz", - "integrity": "sha512-FYPtOmnm5daQnY4m2mgf/06bXkCL2oj1CIs+76tBu80kE1RDH0/ejsVKsiw6O5H3E2J1ruchRpSXTAlyQw1rYA==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-12.3.1.tgz", + "integrity": "sha512-RCkmyKdoe6VFWMzzVm5a9W+a+ptJSusVX+YOrcNy/heklMIWLg0bL+HYFcyYCm8rU2dRq2HuSYTOamDjNs0LZg==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "12.2.0", + "@secretlint/types": "12.3.1", "istextorbinary": "^9.5.0" }, "engines": { @@ -5089,9 +5068,9 @@ } }, "node_modules/@secretlint/types": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-12.2.0.tgz", - "integrity": "sha512-pIqhdWTFMN/cBfpZkAX1A8dqavsFvAdLobbxyMUHBUn/sUgXzyvUp7I52iyTr21EPc/BvOT9lDWdJBkcNz+n7Q==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-12.3.1.tgz", + "integrity": "sha512-Qv3fKvPkzUJpS9Ps6m2EPjC0RdxS2ZZrRfZAhIdl2u0zSjgf+Z0+AaCngmHRR+3Vtbw6s2FrCf4T6mLirm8Hgg==", "dev": true, "license": "MIT", "engines": { @@ -5393,19 +5372,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.5.tgz", - "integrity": "sha512-wnYOpB5vATFKWrY2Z9Alb0KhjZI6AbzU6Fbz3Hq2GnURdRYWB4q+qWivQtSTwXcmWUA3MZ6krfwL6Cq5MAbxsA==", + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz", + "integrity": "sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==", "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", - "@smithy/service-error-classification": "^4.3.0", + "@smithy/service-error-classification": "^4.3.1", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", + "@smithy/util-retry": "^4.3.6", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -5525,9 +5504,9 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.0.tgz", - "integrity": "sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz", + "integrity": "sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.1" @@ -5748,12 +5727,12 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.4.tgz", - "integrity": "sha512-FY1UQQ1VFmMwiYp1GVS4MeaGD5O0blLNYK0xCRHU+mJgeoH/hSY8Ld8sJWKQ6uznkh14HveRGQJncgPyNl9J+A==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.6.tgz", + "integrity": "sha512-p6/FO1n2KxMeQyna067i0uJ6TSbb165ZhnRtCpWh4Foxqbfc6oW+XITaL8QkFJj3KFnDe2URt4gOhgU06EP9ew==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.3.0", + "@smithy/service-error-classification": "^4.3.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, @@ -5806,9 +5785,9 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.2.16", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.16.tgz", - "integrity": "sha512-GtclrKoZ3Lt7jPQ7aTIYKfjY92OgceScftVnkTsG8e1KV8rkvZgN+ny6YSRhd9hxB8rZtwVbmln7NTvE5O3GmQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.3.0.tgz", + "integrity": "sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.1", @@ -5838,24 +5817,24 @@ "license": "MIT" }, "node_modules/@textlint/ast-node-types": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.5.4.tgz", - "integrity": "sha512-bVtB6VEy9U9DpW8cTt25k5T+lz86zV5w6ImePZqY1AXzSuPhqQNT77lkMPxonXzUducEIlSvUu3o7sKw3y9+Sw==", + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.6.0.tgz", + "integrity": "sha512-CxZHFbYAU7J0A4izz31wV2ZZfySR6aVj2OSR6/3tppZm7VV6hM7nA7sutsLwIiBL/v4lsB1RM79l4Dc/VrH4qw==", "dev": true, "license": "MIT" }, "node_modules/@textlint/linter-formatter": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.5.4.tgz", - "integrity": "sha512-D9qJedKBLmAo+kiudop4UKgSxXMi4O8U86KrCidVXZ9RsK0NSVIw6+r2rlMUOExq79iEY81FRENyzmNVRxDBsg==", + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.6.0.tgz", + "integrity": "sha512-IwHRhjwxs0a5t1eNAoKAdV224CDca38LyopPofXpwO/d0J75wBvzf/cBHXNl4TMsLKhYGtR83UprcLEKj/gZsA==", "dev": true, "license": "MIT", "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "15.5.4", - "@textlint/resolver": "15.5.4", - "@textlint/types": "15.5.4", + "@textlint/module-interop": "15.6.0", + "@textlint/resolver": "15.6.0", + "@textlint/types": "15.6.0", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", @@ -5898,27 +5877,27 @@ } }, "node_modules/@textlint/module-interop": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.5.4.tgz", - "integrity": "sha512-JyAUd26ll3IFF87LP0uGoa8Tzw5ZKiYvGs6v8jLlzyND1lUYCI4+2oIAslrODLkf0qwoCaJrBQWM3wsw+asVGQ==", + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.6.0.tgz", + "integrity": "sha512-MHY6pJx9i5kOlrvUSK51887tYZjHAV2qnr6unBm7LtBLGDFo93utdYqHyWep8r9QLsilQdeijWtufJI46z4v4w==", "dev": true, "license": "MIT" }, "node_modules/@textlint/resolver": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.5.4.tgz", - "integrity": "sha512-5GUagtpQuYcmhlOzBGdmVBvDu5lKgVTjwbxtdfoidN4OIqblIxThJHHjazU+ic+/bCIIzI2JcOjHGSaRmE8Gcg==", + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.6.0.tgz", + "integrity": "sha512-T1l2Gd3455pwtm0cTewhX/LLy3bL9z6/Fu/am+jj+jjGfXVoknYkjfkZEKrjHlA7xzay0EfUKnu//teYemLeZw==", "dev": true, "license": "MIT" }, "node_modules/@textlint/types": { - "version": "15.5.4", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.5.4.tgz", - "integrity": "sha512-mY28j2U7nrWmZbxyKnRvB8vJxJab4AxqOobLfb6iozrLelJbqxcOTvBQednadWPfAk9XWaZVMqUr9Nird3mutg==", + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.6.0.tgz", + "integrity": "sha512-CvgYb1PiqF4BGyoZebGWzAJCZ4ChJAZ9gtWjpQIMKE4Xe2KlSwDA8m8MsiZIV321f5Ibx38BMjC1Z/2ZYP2GQg==", "dev": true, "license": "MIT", "dependencies": { - "@textlint/ast-node-types": "15.5.4" + "@textlint/ast-node-types": "15.6.0" } }, "node_modules/@trivago/prettier-plugin-sort-imports": { @@ -7154,9 +7133,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.250.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.250.0.tgz", - "integrity": "sha512-8U8/S9VcmKSc3MHZWiB7P0IecgXoohI8Ya3dgtZMgbzC4mB+MEQmsYBeNgm4vzGQdRos8HjQLnFX1IBlZh7jQA==", + "version": "2.251.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.251.0.tgz", + "integrity": "sha512-H1Jfz2Oyejn+yG24i+By9fZpYfg+E3h1XnFCF2wnt/MyGOTIePRph7MRGkX73ap10ERSpmd0Ly58OLVykFoSQA==", "bundleDependencies": [ "@balena/dockerignore", "@aws-cdk/cloud-assembly-api", @@ -7176,8 +7155,8 @@ "dependencies": { "@aws-cdk/asset-awscli-v1": "2.2.273", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1", - "@aws-cdk/cloud-assembly-api": "^2.2.0", - "@aws-cdk/cloud-assembly-schema": "^53.0.0", + "@aws-cdk/cloud-assembly-api": "^2.2.2", + "@aws-cdk/cloud-assembly-schema": "^53.18.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", "fs-extra": "^11.3.3", @@ -7198,7 +7177,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.2.0", + "version": "2.2.2", "bundleDependencies": [ "jsonschema", "semver" @@ -7214,7 +7193,7 @@ "node": ">= 18.0.0" }, "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=53.0.0" + "@aws-cdk/cloud-assembly-schema": ">=53.15.0" } }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { @@ -7966,9 +7945,9 @@ } }, "node_modules/cdk-from-cfn": { - "version": "0.295.0", - "resolved": "https://registry.npmjs.org/cdk-from-cfn/-/cdk-from-cfn-0.295.0.tgz", - "integrity": "sha512-HNQu3TfNTHZNlxh/o0XxhMMSt3uDFDtMxxO2wZGvZpHwvjZLLFSCHooMbMGj75vtyqNmqKxQdR9WQSTcW3oIpg==", + "version": "0.297.0", + "resolved": "https://registry.npmjs.org/cdk-from-cfn/-/cdk-from-cfn-0.297.0.tgz", + "integrity": "sha512-ZyiugKPe9QYmfXNwbjBhc8sgbos7E0mQfo9P3/vrID8iKTsf5YZswPDu526sBTg4Fpx94N08UJZjLepRxaK4Ng==", "license": "MIT OR Apache-2.0" }, "node_modules/chai": { @@ -8540,12 +8519,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, "node_modules/editions": { "version": "6.22.0", "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", @@ -9793,22 +9766,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -9994,21 +9951,17 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -11254,21 +11207,6 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/javascript-natural-sort": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", @@ -13469,12 +13407,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -13619,26 +13551,29 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/path-to-regexp": { "version": "8.4.0", @@ -14501,17 +14436,17 @@ "license": "MIT" }, "node_modules/secretlint": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-12.2.0.tgz", - "integrity": "sha512-nIl6JNhywewJIJGHNeCpu0/NXs4zyhTriz9683SWNIjH6etDyN/Q/L2fJ4nCxqdl7iZM3MlVtQQMtPDomQINuw==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-12.3.1.tgz", + "integrity": "sha512-wv8TKCjU5hbBxo5jKEX8wIE78VAoL0Ux7pu18+TxtbICMZ2OCbu6EmO3OJLbUbyfUXSPVryNLNmGVgvwY6Z0xw==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-creator": "12.2.0", - "@secretlint/formatter": "12.2.0", - "@secretlint/node": "12.2.0", - "@secretlint/profiler": "12.2.0", - "@secretlint/resolver": "12.2.0", + "@secretlint/config-creator": "12.3.1", + "@secretlint/formatter": "12.3.1", + "@secretlint/node": "12.3.1", + "@secretlint/profiler": "12.3.1", + "@secretlint/resolver": "12.3.1", "debug": "^4.4.3", "globby": "^16.2.0", "read-pkg": "^10.1.0" @@ -14739,6 +14674,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -14998,51 +14934,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string-width/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -15195,28 +15086,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -16956,45 +16825,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", diff --git a/package.json b/package.json index e63de389e..4abd52056 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aws/agentcore", - "version": "0.11.0", + "version": "0.13.1", "description": "CLI for Amazon Bedrock AgentCore", "license": "Apache-2.0", "repository": { @@ -87,7 +87,7 @@ "@aws-sdk/client-sts": "^3.893.0", "@aws-sdk/client-xray": "^3.1003.0", "@aws-sdk/credential-providers": "^3.893.0", - "@aws/agent-inspector": "0.2.1", + "@aws/agent-inspector": "0.3.0", "@commander-js/extra-typings": "^14.0.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", @@ -150,12 +150,14 @@ "overridesComments": { "minimatch": "GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74: minimatch 10.0.0-10.2.2 has ReDoS vulnerabilities. Multiple transitive deps (eslint, typescript-eslint, eslint-plugin-import, eslint-plugin-react, prettier-plugin-sort-imports, aws-cdk-lib) pin older versions. Remove this override once upstream packages update their minimatch dependency to >=10.2.3.", "fast-xml-parser": "GHSA-8gc5-j5rx-235r, GHSA-jp2q-39xq-3w4g: fast-xml-parser <=5.5.6 has entity expansion bypass (CVE-2026-33036, CVE-2026-33349). Transitive via @aws-sdk/xml-builder. Remove once @aws-sdk updates to fast-xml-parser >=5.5.7.", - "@aws-sdk/xml-builder": "aws/aws-sdk-js-v3#7867: @aws-sdk/xml-builder <3.972.14 does not configure maxTotalExpansions on fast-xml-parser, causing 'Entity expansion limit exceeded' on large CloudFormation responses. Remove once @aws-sdk/client-* deps are bumped past 3.972.14." + "@aws-sdk/xml-builder": "aws/aws-sdk-js-v3#7867: @aws-sdk/xml-builder <3.972.14 does not configure maxTotalExpansions on fast-xml-parser, causing 'Entity expansion limit exceeded' on large CloudFormation responses. Remove once @aws-sdk/client-* deps are bumped past 3.972.14.", + "glob": "glob <12 is deprecated and emits npm install warnings (https://github.com/isaacs/node-glob). Pulled in transitively via archiver-utils@5.0.2 (latest), which still pins glob@^10.0.0. archiver-utils only uses glob.sync(pattern, options), which remains compatible in glob@13. Remove this override once archiver-utils updates its glob dependency." }, "overrides": { "minimatch": "10.2.4", "fast-xml-parser": "5.5.7", - "@aws-sdk/xml-builder": "3.972.15" + "@aws-sdk/xml-builder": "3.972.15", + "glob": "^13.0.0" }, "engines": { "node": ">=20" diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index 15877fa42..001319147 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"], @@ -665,6 +690,10 @@ "type": "string", "minLength": 1 }, + "endpoint": { + "type": "string", + "minLength": 1 + }, "evaluators": { "minItems": 1, "type": "array", @@ -716,6 +745,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 +1236,11 @@ "required": ["policyEngineName", "mode"], "additionalProperties": false }, + "executionRoleArn": { + "type": "string", + "maxLength": 2048, + "pattern": "^arn:[^:]+:iam::\\d{12}:role\\/.+" + }, "tags": { "type": "object", "propertyNames": { @@ -1804,6 +1844,308 @@ "required": ["name"], "additionalProperties": false } + }, + "configBundles": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,99}$" + }, + "type": { + "default": "ConfigurationBundle", + "type": "string", + "const": "ConfigurationBundle" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "components": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "configuration": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["configuration"], + "additionalProperties": false + } + }, + "branchName": { + "type": "string", + "maxLength": 128 + }, + "commitMessage": { + "type": "string", + "maxLength": 500 + } + }, + "required": ["name", "components"], + "additionalProperties": false + } + }, + "abTests": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 48, + "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,47}$" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "mode": { + "default": "config-bundle", + "type": "string", + "enum": ["config-bundle", "target-based"] + }, + "gatewayRef": { + "type": "string", + "minLength": 1 + }, + "roleArn": { + "type": "string", + "minLength": 1 + }, + "variants": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["C", "T1"] + }, + "weight": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "variantConfiguration": { + "anyOf": [ + { + "type": "object", + "properties": { + "configurationBundle": { + "type": "object", + "properties": { + "bundleArn": { + "type": "string", + "minLength": 1 + }, + "bundleVersion": { + "type": "string", + "minLength": 1 + } + }, + "required": ["bundleArn", "bundleVersion"], + "additionalProperties": false + }, + "target": { + "not": {} + } + }, + "required": ["configurationBundle"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "configurationBundle": { + "not": {} + }, + "target": { + "type": "object", + "properties": { + "targetName": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "required": ["targetName"], + "additionalProperties": false + } + }, + "required": ["target"], + "additionalProperties": false + } + ] + } + }, + "required": ["name", "weight", "variantConfiguration"], + "additionalProperties": false + } + }, + "evaluationConfig": { + "anyOf": [ + { + "type": "object", + "properties": { + "onlineEvaluationConfigArn": { + "type": "string", + "minLength": 1 + } + }, + "required": ["onlineEvaluationConfigArn"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "perVariantOnlineEvaluationConfig": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "object", + "properties": { + "treatmentName": { + "type": "string", + "enum": ["C", "T1"] + }, + "onlineEvaluationConfigArn": { + "type": "string", + "minLength": 1 + } + }, + "required": ["treatmentName", "onlineEvaluationConfigArn"], + "additionalProperties": false + } + } + }, + "required": ["perVariantOnlineEvaluationConfig"], + "additionalProperties": false + } + ] + }, + "gatewayFilter": { + "type": "object", + "properties": { + "targetPaths": { + "maxItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + }, + "required": ["targetPaths"], + "additionalProperties": false + }, + "trafficAllocationConfig": { + "type": "object", + "properties": { + "routeOnHeader": { + "type": "object", + "properties": { + "headerName": { + "type": "string", + "minLength": 1 + } + }, + "required": ["headerName"], + "additionalProperties": false + } + }, + "required": ["routeOnHeader"], + "additionalProperties": false + }, + "maxDurationDays": { + "type": "integer", + "minimum": 1, + "maximum": 90 + }, + "enableOnCreate": { + "type": "boolean" + }, + "promoted": { + "type": "boolean" + } + }, + "required": ["name", "gatewayRef", "variants", "evaluationConfig"], + "additionalProperties": false + } + }, + "httpGateways": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-zA-Z][a-zA-Z0-9-]*$" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "runtimeRef": { + "type": "string", + "minLength": 1 + }, + "roleArn": { + "type": "string", + "minLength": 1 + }, + "targets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "runtimeRef": { + "type": "string", + "minLength": 1 + }, + "qualifier": { + "default": "DEFAULT", + "type": "string", + "minLength": 1 + } + }, + "required": ["name", "runtimeRef"], + "additionalProperties": false + } + } + }, + "required": ["name", "runtimeRef"], + "additionalProperties": false + } } }, "required": ["name", "version"], diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index 0afdcf93d..29cb5d745 100644 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -81,7 +81,8 @@ function resolveCdkPath() { log('Starting bundle process...'); -const timestamp = Math.floor(Date.now() / 1000); +const now = new Date(); +const timestamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14); log(`Bundle timestamp: ${timestamp}`); // Helper to bump a package version with a unique e2e timestamp tag. @@ -91,7 +92,9 @@ function bumpVersion(pkgDir) { const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); const originalVersion = pkg.version; const baseVersion = originalVersion.split('-')[0]; - pkg.version = `${baseVersion}-${timestamp}`; + const prerelease = originalVersion.includes('-') ? originalVersion.split('-').slice(1).join('-') : ''; + const tag = prerelease ? `${prerelease}-${timestamp}` : timestamp; + pkg.version = `${baseVersion}-${tag}`; fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + '\n'); log(`Bumped ${pkg.name} version: ${originalVersion} -> ${pkg.version}`); return { pkgJsonPath, originalVersion, bumpedVersion: pkg.version }; diff --git a/scripts/run-e2e-local.sh b/scripts/run-e2e-local.sh new file mode 100755 index 000000000..81b9af3c1 --- /dev/null +++ b/scripts/run-e2e-local.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Run E2E tests locally, replicating the GitHub Actions e2e-tests.yml workflow. +# +# Required env vars: +# E2E_ROLE_ARN — IAM role ARN to assume (grants access to the test account) +# E2E_SECRET_ARN — Secrets Manager ARN containing ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY +# +# Optional env vars: +# AWS_REGION — defaults to us-east-1 +# +# Usage: +# export E2E_ROLE_ARN=arn:aws:iam:::role/ +# export E2E_SECRET_ARN=arn:aws:secretsmanager:::secret: +# ./scripts/run-e2e-local.sh # runs strands-bedrock.test.ts (CI default) +# ./scripts/run-e2e-local.sh --all # runs the full e2e suite +# ./scripts/run-e2e-local.sh e2e-tests/foo.test.ts # runs a specific test file +# +# Prerequisites: aws CLI, node >=20.19, npm, git, uv, jq + +set -euo pipefail + +ROLE_ARN="${E2E_ROLE_ARN:-}" +SECRET_ARN="${E2E_SECRET_ARN:-}" +AWS_REGION="${AWS_REGION:-us-east-1}" + +if [[ -z "$ROLE_ARN" ]]; then + echo "❌ E2E_ROLE_ARN is not set. Export it before running this script:" + echo " export E2E_ROLE_ARN=arn:aws:iam:::role/" + exit 1 +fi + +if [[ -z "$SECRET_ARN" ]]; then + echo "❌ E2E_SECRET_ARN is not set. Export it before running this script:" + echo " export E2E_SECRET_ARN=arn:aws:secretsmanager:::secret:" + exit 1 +fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# ── Parse arguments ──────────────────────────────────────────────────────────── +RUN_ALL=false +TEST_FILES=() +for arg in "$@"; do + if [[ "$arg" == "--all" ]]; then + RUN_ALL=true + else + TEST_FILES+=("$arg") + fi +done + +echo "=== Assuming IAM role ===" +CREDS=$(aws sts assume-role \ + --role-arn "$ROLE_ARN" \ + --role-session-name "local-e2e-$(date +%s)" \ + --duration-seconds 3600 \ + --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' \ + --output text) + +export AWS_ACCESS_KEY_ID=$(echo "$CREDS" | awk '{print $1}') +export AWS_SECRET_ACCESS_KEY=$(echo "$CREDS" | awk '{print $2}') +export AWS_SESSION_TOKEN=$(echo "$CREDS" | awk '{print $3}') +export AWS_REGION + +echo "✅ Assumed role successfully" + +echo "=== Fetching API keys from Secrets Manager ===" +SECRET_JSON=$(aws secretsmanager get-secret-value \ + --secret-id "$SECRET_ARN" \ + --region "$AWS_REGION" \ + --query SecretString \ + --output text) + +# Mirror the GitHub workflow: parse-json-secrets maps keys to E2E_ then +# the workflow maps them to the bare names the tests expect. +export ANTHROPIC_API_KEY=$(echo "$SECRET_JSON" | jq -r '.ANTHROPIC_API_KEY // empty') +export OPENAI_API_KEY=$(echo "$SECRET_JSON" | jq -r '.OPENAI_API_KEY // empty') +export GEMINI_API_KEY=$(echo "$SECRET_JSON" | jq -r '.GEMINI_API_KEY // empty') + +echo "✅ Secrets loaded (keys present: $(echo "$SECRET_JSON" | jq -r 'keys | join(", ")')" + +echo "=== Setting AWS account env var ===" +export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +echo "✅ AWS_ACCOUNT_ID=$AWS_ACCOUNT_ID AWS_REGION=$AWS_REGION" + +echo "=== Configuring git (required for agentcore create) ===" +git config --global user.email "ci@local" 2>/dev/null || true +git config --global user.name "Local E2E" 2>/dev/null || true + +cd "$REPO_ROOT" + +echo "=== Installing dependencies ===" +npm ci + +echo "=== Building CLI ===" +npm run build + +echo "=== Installing CLI globally ===" +TARBALL=$(npm pack | tail -1) +npm install -g "$TARBALL" +echo "✅ Installed: $(agentcore --version)" + +echo "=== Running E2E tests ===" +if [[ "$RUN_ALL" == "true" ]]; then + echo "Running full e2e suite" + npx vitest run --project e2e +elif [[ ${#TEST_FILES[@]} -gt 0 ]]; then + echo "Running: ${TEST_FILES[*]}" + npx vitest run --project e2e "${TEST_FILES[@]}" +else + echo "Running default: e2e-tests/strands-bedrock.test.ts" + npx vitest run --project e2e e2e-tests/strands-bedrock.test.ts +fi diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 8fa17f318..0962b1b0e 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -382,6 +382,7 @@ test('AgentCoreStack synthesizes with empty spec', () => { credentials: [], evaluators: [], onlineEvalConfigs: [], + configBundles: [], policyEngines: [], agentCoreGateways: [], mcpRuntimeTools: [], @@ -3700,9 +3701,15 @@ Thumbs.db exports[`Assets Directory Snapshots > Python framework assets > python/python/http/langchain_langgraph/base/main.py should match snapshot 1`] = ` "import os -from langchain_core.messages import HumanMessage +from typing import Any + +from langchain_core.messages import HumanMessage{{#if hasConfigBundle}}, SystemMessage{{/if}} from langgraph.prebuilt import create_react_agent from langchain.tools import tool +{{#if hasConfigBundle}} +from langchain_core.callbacks import BaseCallbackHandler +from bedrock_agentcore.runtime.context import BedrockAgentCoreContext +{{/if}} from opentelemetry.instrumentation.langchain import LangchainInstrumentor from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model @@ -3726,6 +3733,14 @@ def get_or_create_model(): return _llm +DEFAULT_SYSTEM_PROMPT = """ +You are a helpful assistant. Use tools when appropriate. +{{#if sessionStorageMountPath}} +You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. +{{/if}} +""" + + # Define a simple function tool @tool def add_numbers(a: int, b: int) -> int: @@ -3789,13 +3804,28 @@ def list_files(directory: str = "") -> str: tools.extend([file_read, file_write, list_files]) {{/if}} -SYSTEM_PROMPT = """ -You are a helpful assistant. Use tools when appropriate. -{{#if sessionStorageMountPath}} -You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. -{{/if}} -""" +{{#if hasConfigBundle}} +class ConfigBundleCallback(BaseCallbackHandler): + """Injects config bundle values into LangGraph agent at runtime. + + BedrockAgentCoreContext.get_config_bundle() fetches the component configuration + for the current runtime ARN from the config bundle service. The SDK caches the + result and refreshes on bundle version changes. + """ + + def on_chain_start(self, serialized: dict, inputs: dict, **kwargs: Any) -> None: + config = BedrockAgentCoreContext.get_config_bundle() + prompt = config.get("systemPrompt", DEFAULT_SYSTEM_PROMPT) + + messages = inputs.get("messages", []) + if messages and isinstance(messages[0], SystemMessage): + messages[0] = SystemMessage(content=prompt) + else: + messages.insert(0, SystemMessage(content=prompt)) + inputs["messages"] = messages + +{{/if}} @app.entrypoint async def invoke(payload, context): @@ -3814,7 +3844,21 @@ async def invoke(payload, context): mcp_tools = await mcp_client.get_tools() # Define the agent using create_react_agent - graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=SYSTEM_PROMPT) +{{#if hasConfigBundle}} + graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=DEFAULT_SYSTEM_PROMPT) + callback = ConfigBundleCallback() + + # Process the user prompt + prompt = payload.get("prompt", "What can you help me with?") + log.info(f"Agent input: {prompt}") + + # Run the agent with config bundle callback + result = await graph.ainvoke( + {"messages": [HumanMessage(content=prompt)]}, + config={"callbacks": [callback]}, + ) +{{else}} + graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=DEFAULT_SYSTEM_PROMPT) # Process the user prompt prompt = payload.get("prompt", "What can you help me with?") @@ -3822,6 +3866,7 @@ async def invoke(payload, context): # Run the agent result = await graph.ainvoke({"messages": [HumanMessage(content=prompt)]}) +{{/if}} # Return result output = result["messages"][-1].content @@ -4596,7 +4641,13 @@ Thumbs.db" `; exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/base/main.py should match snapshot 1`] = ` -"from strands import Agent, tool +"from typing import Any + +from strands import Agent, tool +{{#if hasConfigBundle}} +from strands.hooks import HookProvider, HookRegistry, BeforeInvocationEvent, BeforeToolCallEvent +from bedrock_agentcore.runtime.context import BedrockAgentCoreContext +{{/if}} from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model {{#if hasGateway}} @@ -4621,11 +4672,26 @@ mcp_clients = get_all_gateway_mcp_clients() mcp_clients = [get_streamable_http_mcp_client()] {{/if}} +DEFAULT_SYSTEM_PROMPT = """ +You are a helpful assistant. Use tools when appropriate. +{{#if sessionStorageMountPath}} +You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. +{{/if}} +""" + +{{#if hasConfigBundle}} +DEFAULT_TOOL_DESC = "Return the sum of two numbers" +{{/if}} + # Define a collection of tools used by the model tools = [] # Define a simple function tool +{{#if hasConfigBundle}} +@tool(description=DEFAULT_TOOL_DESC) +{{else}} @tool +{{/if}} def add_numbers(a: int, b: int) -> int: """Return the sum of two numbers""" return a+b @@ -4689,12 +4755,39 @@ for mcp_client in mcp_clients: if mcp_client: tools.append(mcp_client) -SYSTEM_PROMPT = """ -You are a helpful assistant. Use tools when appropriate. -{{#if sessionStorageMountPath}} -You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. +{{#if hasConfigBundle}} + +class ConfigBundleHook(HookProvider): + """Injects config bundle values (system prompt, tool descriptions) before each invocation. + + BedrockAgentCoreContext.get_config_bundle() fetches the component configuration + for the current runtime ARN from the config bundle service. The SDK caches the + result and refreshes on bundle version changes. + """ + + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + registry.add_callback(BeforeInvocationEvent, self._inject_system_prompt) + registry.add_callback(BeforeToolCallEvent, self._override_tool_desc) + + def _inject_system_prompt(self, event: BeforeInvocationEvent) -> None: + config = BedrockAgentCoreContext.get_config_bundle() + prompt = config.get("systemPrompt", DEFAULT_SYSTEM_PROMPT) + + if prompt != event.agent.system_prompt: + event.agent.system_prompt = prompt + + def _override_tool_desc(self, event: BeforeToolCallEvent) -> None: + config = BedrockAgentCoreContext.get_config_bundle() + tool_descs = config.get("toolDescriptions", {}) + + tool_name = event.tool_use["name"] + override = tool_descs.get(tool_name) + if override and event.selected_tool: + spec = event.selected_tool.tool_spec + if spec and "description" in spec: + spec["description"] = override + {{/if}} -""" {{#if hasMemory}} def agent_factory(): @@ -4706,13 +4799,23 @@ def agent_factory(): cache[key] = Agent( model=load_model(), session_manager=get_memory_session_manager(session_id, user_id), - system_prompt=SYSTEM_PROMPT, - tools=tools + system_prompt=DEFAULT_SYSTEM_PROMPT, + tools=tools{{#if hasConfigBundle}}, + hooks=[ConfigBundleHook()]{{/if}} ) return cache[key] return get_or_create_agent get_or_create_agent = agent_factory() {{else}} +{{#if hasConfigBundle}} +def create_agent(): + return Agent( + model=load_model(), + system_prompt=DEFAULT_SYSTEM_PROMPT, + tools=tools, + hooks=[ConfigBundleHook()], + ) +{{else}} _agent = None def get_or_create_agent(): @@ -4720,11 +4823,12 @@ def get_or_create_agent(): if _agent is None: _agent = Agent( model=load_model(), - system_prompt=SYSTEM_PROMPT, + system_prompt=DEFAULT_SYSTEM_PROMPT, tools=tools ) return _agent {{/if}} +{{/if}} @app.entrypoint @@ -4735,8 +4839,12 @@ async def invoke(payload, context): session_id = getattr(context, 'session_id', 'default-session') user_id = getattr(context, 'user_id', 'default-user') agent = get_or_create_agent(session_id, user_id) +{{else}} +{{#if hasConfigBundle}} + agent = create_agent() {{else}} agent = get_or_create_agent() +{{/if}} {{/if}} # Execute and format response 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/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index df5c767f9..79282f729 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -14,6 +14,7 @@ test('AgentCoreStack synthesizes with empty spec', () => { credentials: [], evaluators: [], onlineEvalConfigs: [], + configBundles: [], policyEngines: [], agentCoreGateways: [], mcpRuntimeTools: [], diff --git a/src/assets/python/http/langchain_langgraph/base/main.py b/src/assets/python/http/langchain_langgraph/base/main.py index dcb9eb13c..773253da0 100644 --- a/src/assets/python/http/langchain_langgraph/base/main.py +++ b/src/assets/python/http/langchain_langgraph/base/main.py @@ -1,7 +1,13 @@ import os -from langchain_core.messages import HumanMessage +from typing import Any + +from langchain_core.messages import HumanMessage{{#if hasConfigBundle}}, SystemMessage{{/if}} from langgraph.prebuilt import create_react_agent from langchain.tools import tool +{{#if hasConfigBundle}} +from langchain_core.callbacks import BaseCallbackHandler +from bedrock_agentcore.runtime.context import BedrockAgentCoreContext +{{/if}} from opentelemetry.instrumentation.langchain import LangchainInstrumentor from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model @@ -25,6 +31,14 @@ def get_or_create_model(): return _llm +DEFAULT_SYSTEM_PROMPT = """ +You are a helpful assistant. Use tools when appropriate. +{{#if sessionStorageMountPath}} +You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. +{{/if}} +""" + + # Define a simple function tool @tool def add_numbers(a: int, b: int) -> int: @@ -88,13 +102,28 @@ def list_files(directory: str = "") -> str: tools.extend([file_read, file_write, list_files]) {{/if}} -SYSTEM_PROMPT = """ -You are a helpful assistant. Use tools when appropriate. -{{#if sessionStorageMountPath}} -You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. -{{/if}} -""" +{{#if hasConfigBundle}} + +class ConfigBundleCallback(BaseCallbackHandler): + """Injects config bundle values into LangGraph agent at runtime. + + BedrockAgentCoreContext.get_config_bundle() fetches the component configuration + for the current runtime ARN from the config bundle service. The SDK caches the + result and refreshes on bundle version changes. + """ + + def on_chain_start(self, serialized: dict, inputs: dict, **kwargs: Any) -> None: + config = BedrockAgentCoreContext.get_config_bundle() + prompt = config.get("systemPrompt", DEFAULT_SYSTEM_PROMPT) + messages = inputs.get("messages", []) + if messages and isinstance(messages[0], SystemMessage): + messages[0] = SystemMessage(content=prompt) + else: + messages.insert(0, SystemMessage(content=prompt)) + inputs["messages"] = messages + +{{/if}} @app.entrypoint async def invoke(payload, context): @@ -113,7 +142,21 @@ async def invoke(payload, context): mcp_tools = await mcp_client.get_tools() # Define the agent using create_react_agent - graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=SYSTEM_PROMPT) +{{#if hasConfigBundle}} + graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=DEFAULT_SYSTEM_PROMPT) + callback = ConfigBundleCallback() + + # Process the user prompt + prompt = payload.get("prompt", "What can you help me with?") + log.info(f"Agent input: {prompt}") + + # Run the agent with config bundle callback + result = await graph.ainvoke( + {"messages": [HumanMessage(content=prompt)]}, + config={"callbacks": [callback]}, + ) +{{else}} + graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools, prompt=DEFAULT_SYSTEM_PROMPT) # Process the user prompt prompt = payload.get("prompt", "What can you help me with?") @@ -121,6 +164,7 @@ async def invoke(payload, context): # Run the agent result = await graph.ainvoke({"messages": [HumanMessage(content=prompt)]}) +{{/if}} # Return result output = result["messages"][-1].content diff --git a/src/assets/python/http/strands/base/main.py b/src/assets/python/http/strands/base/main.py index f7b69d3e4..0cc8771ad 100644 --- a/src/assets/python/http/strands/base/main.py +++ b/src/assets/python/http/strands/base/main.py @@ -1,4 +1,10 @@ +from typing import Any + from strands import Agent, tool +{{#if hasConfigBundle}} +from strands.hooks import HookProvider, HookRegistry, BeforeInvocationEvent, BeforeToolCallEvent +from bedrock_agentcore.runtime.context import BedrockAgentCoreContext +{{/if}} from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model {{#if hasGateway}} @@ -23,11 +29,26 @@ mcp_clients = [get_streamable_http_mcp_client()] {{/if}} +DEFAULT_SYSTEM_PROMPT = """ +You are a helpful assistant. Use tools when appropriate. +{{#if sessionStorageMountPath}} +You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. +{{/if}} +""" + +{{#if hasConfigBundle}} +DEFAULT_TOOL_DESC = "Return the sum of two numbers" +{{/if}} + # Define a collection of tools used by the model tools = [] # Define a simple function tool +{{#if hasConfigBundle}} +@tool(description=DEFAULT_TOOL_DESC) +{{else}} @tool +{{/if}} def add_numbers(a: int, b: int) -> int: """Return the sum of two numbers""" return a+b @@ -91,12 +112,39 @@ def list_files(directory: str = "") -> str: if mcp_client: tools.append(mcp_client) -SYSTEM_PROMPT = """ -You are a helpful assistant. Use tools when appropriate. -{{#if sessionStorageMountPath}} -You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions. +{{#if hasConfigBundle}} + +class ConfigBundleHook(HookProvider): + """Injects config bundle values (system prompt, tool descriptions) before each invocation. + + BedrockAgentCoreContext.get_config_bundle() fetches the component configuration + for the current runtime ARN from the config bundle service. The SDK caches the + result and refreshes on bundle version changes. + """ + + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + registry.add_callback(BeforeInvocationEvent, self._inject_system_prompt) + registry.add_callback(BeforeToolCallEvent, self._override_tool_desc) + + def _inject_system_prompt(self, event: BeforeInvocationEvent) -> None: + config = BedrockAgentCoreContext.get_config_bundle() + prompt = config.get("systemPrompt", DEFAULT_SYSTEM_PROMPT) + + if prompt != event.agent.system_prompt: + event.agent.system_prompt = prompt + + def _override_tool_desc(self, event: BeforeToolCallEvent) -> None: + config = BedrockAgentCoreContext.get_config_bundle() + tool_descs = config.get("toolDescriptions", {}) + + tool_name = event.tool_use["name"] + override = tool_descs.get(tool_name) + if override and event.selected_tool: + spec = event.selected_tool.tool_spec + if spec and "description" in spec: + spec["description"] = override + {{/if}} -""" {{#if hasMemory}} def agent_factory(): @@ -108,13 +156,23 @@ def get_or_create_agent(session_id, user_id): cache[key] = Agent( model=load_model(), session_manager=get_memory_session_manager(session_id, user_id), - system_prompt=SYSTEM_PROMPT, - tools=tools + system_prompt=DEFAULT_SYSTEM_PROMPT, + tools=tools{{#if hasConfigBundle}}, + hooks=[ConfigBundleHook()]{{/if}} ) return cache[key] return get_or_create_agent get_or_create_agent = agent_factory() {{else}} +{{#if hasConfigBundle}} +def create_agent(): + return Agent( + model=load_model(), + system_prompt=DEFAULT_SYSTEM_PROMPT, + tools=tools, + hooks=[ConfigBundleHook()], + ) +{{else}} _agent = None def get_or_create_agent(): @@ -122,11 +180,12 @@ def get_or_create_agent(): if _agent is None: _agent = Agent( model=load_model(), - system_prompt=SYSTEM_PROMPT, + system_prompt=DEFAULT_SYSTEM_PROMPT, tools=tools ) return _agent {{/if}} +{{/if}} @app.entrypoint @@ -137,8 +196,12 @@ async def invoke(payload, context): session_id = getattr(context, 'session_id', 'default-session') user_id = getattr(context, 'user_id', 'default-user') agent = get_or_create_agent(session_id, user_id) +{{else}} +{{#if hasConfigBundle}} + agent = create_agent() {{else}} agent = get_or_create_agent() +{{/if}} {{/if}} # Execute and format response diff --git a/src/cli/__tests__/global-config.test.ts b/src/cli/__tests__/global-config.test.ts index 2851a13a4..6e2038973 100644 --- a/src/cli/__tests__/global-config.test.ts +++ b/src/cli/__tests__/global-config.test.ts @@ -1,4 +1,9 @@ -import { getOrCreateInstallationId, readGlobalConfig, updateGlobalConfig } from '../global-config'; +import { + getOrCreateInstallationId, + readGlobalConfig, + readGlobalConfigSync, + updateGlobalConfig, +} from '../../lib/schemas/io/global-config'; import { createTempConfig } from './helpers/temp-config'; import { readFile, writeFile } from 'fs/promises'; import { afterAll, beforeEach, describe, expect, it } from 'vitest'; @@ -21,10 +26,29 @@ describe('global-config', () => { it('returns empty object when file is missing or invalid', async () => { expect(await readGlobalConfig(tmp.testDir + '/nonexistent.json')).toEqual({}); - await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: 'false' } })); + await writeFile(tmp.configFile, 'not json'); expect(await readGlobalConfig(tmp.configFile)).toEqual({}); }); + it('drops invalid fields while preserving valid ones', async () => { + await writeFile( + tmp.configFile, + JSON.stringify({ + transactionSearchIndexPercentage: 'not-a-number', + uvIndex: 'https://valid.url', + telemetry: { enabled: 'yes', endpoint: 'https://example.com' }, + }) + ); + + const config = await readGlobalConfig(tmp.configFile); + + expect(config).toEqual({ + transactionSearchIndexPercentage: undefined, + uvIndex: 'https://valid.url', + telemetry: { enabled: undefined, endpoint: 'https://example.com' }, + }); + }); + it('preserves unknown fields via passthrough', async () => { const full = { installationId: 'abc-123', @@ -39,6 +63,21 @@ describe('global-config', () => { }); }); + describe('readGlobalConfigSync', () => { + it('returns parsed config when file exists', async () => { + await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: false } })); + + expect(readGlobalConfigSync(tmp.configFile)).toEqual({ telemetry: { enabled: false } }); + }); + + it('returns empty object when file is missing or invalid', async () => { + expect(readGlobalConfigSync(tmp.testDir + '/nonexistent.json')).toEqual({}); + + await writeFile(tmp.configFile, 'not json'); + expect(readGlobalConfigSync(tmp.configFile)).toEqual({}); + }); + }); + describe('updateGlobalConfig', () => { it('creates directory and writes config when none exists', async () => { const fresh = createTempConfig('gc-fresh'); diff --git a/src/cli/aws/__tests__/agentcore-ab-tests.test.ts b/src/cli/aws/__tests__/agentcore-ab-tests.test.ts new file mode 100644 index 000000000..94dca3bdb --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-ab-tests.test.ts @@ -0,0 +1,345 @@ +import { createABTest, deleteABTest, getABTest, listABTests, updateABTest } from '../agentcore-ab-tests.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +vi.mock('../account', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + sessionToken: 'TOKEN', + }), +})); + +vi.mock('@smithy/signature-v4', () => ({ + SignatureV4: class { + // eslint-disable-next-line @typescript-eslint/require-await + async sign(request: { headers: Record }) { + return { headers: { ...request.headers, Authorization: 'signed' } }; + } + }, +})); + +vi.mock('@aws-crypto/sha256-js', () => ({ + Sha256: class {}, +})); + +vi.mock('@aws-sdk/credential-provider-node', () => ({ + defaultProvider: vi.fn(), +})); + +function mockJsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +describe('agentcore-ab-tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createABTest', () => { + it('sends POST to /ab-tests with correct body', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + abTestId: 'abt-001', + abTestArn: 'arn:abt:001', + name: 'MyTest', + status: 'CREATED', + executionStatus: 'STOPPED', + createdAt: '2026-01-01T00:00:00Z', + }) + ); + + const result = await createABTest({ + region: 'us-east-1', + name: 'MyTest', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-1', + roleArn: 'arn:aws:iam::123:role/TestRole', + variants: [ + { + name: 'C', + weight: 80, + variantConfiguration: { configurationBundle: { bundleArn: 'arn:bundle:c', bundleVersion: 'v1' } }, + }, + { + name: 'T1', + weight: 20, + variantConfiguration: { configurationBundle: { bundleArn: 'arn:bundle:t', bundleVersion: 'v1' } }, + }, + ], + evaluationConfig: { onlineEvaluationConfigArn: 'arn:eval:config' }, + }); + + expect(result.abTestId).toBe('abt-001'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/ab-tests'), + expect.objectContaining({ method: 'POST' }) + ); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.name).toBe('MyTest'); + expect(body.gatewayArn).toBe('arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-1'); + expect(body.variants).toHaveLength(2); + expect(body.clientToken).toBeDefined(); + }); + + it('omits optional fields when not provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + abTestId: 'abt-002', + abTestArn: 'arn:abt:002', + status: 'CREATED', + executionStatus: 'STOPPED', + createdAt: '2026-01-01T00:00:00Z', + }) + ); + + await createABTest({ + region: 'us-east-1', + name: 'Test', + gatewayArn: 'arn:gw', + roleArn: 'arn:role', + variants: [], + evaluationConfig: { onlineEvaluationConfigArn: 'arn:eval' }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.description).toBeUndefined(); + expect(body.trafficAllocationConfig).toBeUndefined(); + expect(body.maxDurationDays).toBeUndefined(); + expect(body.enableOnCreate).toBeUndefined(); + }); + + it('includes optional fields when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + abTestId: 'abt-003', + abTestArn: 'arn:abt:003', + status: 'CREATED', + executionStatus: 'RUNNING', + createdAt: '2026-01-01T00:00:00Z', + }) + ); + + await createABTest({ + region: 'us-east-1', + name: 'Test', + description: 'A description', + gatewayArn: 'arn:gw', + roleArn: 'arn:role', + variants: [], + evaluationConfig: { onlineEvaluationConfigArn: 'arn:eval' }, + trafficAllocationConfig: { routeOnHeader: { headerName: 'X-AB' } }, + maxDurationDays: 30, + enableOnCreate: true, + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.description).toBe('A description'); + expect(body.trafficAllocationConfig).toEqual({ routeOnHeader: { headerName: 'X-AB' } }); + expect(body.maxDurationDays).toBe(30); + expect(body.enableOnCreate).toBe(true); + }); + + it('throws on non-ok response', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + text: () => Promise.resolve('Bad Request'), + }); + + await expect( + createABTest({ + region: 'us-east-1', + name: 'Test', + gatewayArn: 'arn:gw', + roleArn: 'arn:role', + variants: [], + evaluationConfig: { onlineEvaluationConfigArn: 'arn:eval' }, + }) + ).rejects.toThrow('ABTest API error (400)'); + }); + }); + + describe('getABTest', () => { + it('sends GET to /ab-tests/{id}', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + abTestId: 'abt-123', + abTestArn: 'arn:abt:123', + name: 'MyTest', + status: 'ACTIVE', + executionStatus: 'RUNNING', + gatewayArn: 'arn:gw', + roleArn: 'arn:role', + variants: [], + evaluationConfig: { onlineEvaluationConfigArn: 'arn:eval' }, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + results: { + analysisTimestamp: '2026-01-02T00:00:00Z', + evaluatorMetrics: [], + }, + }) + ); + + const result = await getABTest({ region: 'us-east-1', abTestId: 'abt-123' }); + + expect(result.abTestId).toBe('abt-123'); + expect(result.results).toBeDefined(); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/ab-tests/abt-123'), + expect.objectContaining({ method: 'GET' }) + ); + }); + }); + + describe('updateABTest', () => { + it('sends PUT to /ab-tests/{id} with only defined fields', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + abTestId: 'abt-123', + abTestArn: 'arn:abt:123', + status: 'ACTIVE', + executionStatus: 'PAUSED', + updatedAt: '2026-01-02T00:00:00Z', + }) + ); + + await updateABTest({ + region: 'us-east-1', + abTestId: 'abt-123', + executionStatus: 'PAUSED', + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/ab-tests/abt-123'), + expect.objectContaining({ method: 'PUT' }) + ); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.executionStatus).toBe('PAUSED'); + expect(body.clientToken).toBeDefined(); + expect(body.name).toBeUndefined(); + expect(body.description).toBeUndefined(); + expect(body.variants).toBeUndefined(); + }); + + it('includes all provided fields', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + abTestId: 'abt-123', + abTestArn: 'arn:abt:123', + status: 'ACTIVE', + executionStatus: 'RUNNING', + updatedAt: '2026-01-02T00:00:00Z', + }) + ); + + await updateABTest({ + region: 'us-east-1', + abTestId: 'abt-123', + name: 'Updated', + description: 'New desc', + maxDurationDays: 60, + roleArn: 'arn:new-role', + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.name).toBe('Updated'); + expect(body.description).toBe('New desc'); + expect(body.maxDurationDays).toBe(60); + expect(body.roleArn).toBe('arn:new-role'); + }); + }); + + describe('deleteABTest', () => { + it('sends DELETE to /ab-tests/{id} and returns success', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({}, 204)); + + const result = await deleteABTest({ region: 'us-east-1', abTestId: 'abt-123' }); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/ab-tests/abt-123'), + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('returns error on 404', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + text: () => Promise.resolve('Not Found'), + }); + + const result = await deleteABTest({ region: 'us-east-1', abTestId: 'abt-999' }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]![0]).toContain('/ab-tests/abt-999'); + expect(result.success).toBe(false); + expect(result.error).toContain('ABTest API error (404)'); + }); + + it('returns error on network failure', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await deleteABTest({ region: 'us-east-1', abTestId: 'abt-123' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + }); + + describe('listABTests', () => { + it('sends GET to /ab-tests', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + abTests: [ + { + abTestId: 'abt-1', + abTestArn: 'arn:abt:1', + name: 'Test1', + status: 'ACTIVE', + executionStatus: 'RUNNING', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + }) + ); + + const result = await listABTests({ region: 'us-east-1' }); + + expect(result.abTests).toHaveLength(1); + expect(result.abTests[0]!.name).toBe('Test1'); + }); + + it('passes maxResults and nextToken as query params', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ abTests: [] })); + + await listABTests({ region: 'us-east-1', maxResults: 10, nextToken: 'abc' }); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain('maxResults=10'); + expect(url).toContain('nextToken=abc'); + }); + + it('returns empty array when response has no abTests', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({})); + + const result = await listABTests({ region: 'us-east-1' }); + + expect(result.abTests).toEqual([]); + }); + }); +}); diff --git a/src/cli/aws/__tests__/agentcore-http-gateways.test.ts b/src/cli/aws/__tests__/agentcore-http-gateways.test.ts new file mode 100644 index 000000000..f9ace9a7a --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-http-gateways.test.ts @@ -0,0 +1,235 @@ +import { createHttpGatewayTarget, getHttpGateway, listHttpGatewayTargets } from '../agentcore-http-gateways.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +vi.mock('../account', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + sessionToken: 'TOKEN', + }), +})); + +vi.mock('@smithy/signature-v4', () => ({ + SignatureV4: class { + // eslint-disable-next-line @typescript-eslint/require-await + async sign(request: { headers: Record }) { + return { headers: { ...request.headers, Authorization: 'signed' } }; + } + }, +})); + +vi.mock('@aws-crypto/sha256-js', () => ({ + Sha256: class {}, +})); + +vi.mock('@aws-sdk/credential-provider-node', () => ({ + defaultProvider: vi.fn(), +})); + +function mockJsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +describe('agentcore-http-gateways', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createHttpGatewayTarget', () => { + it('sends agentcoreRuntime in request body', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + targetId: 'tgt-001', + name: 'my-target', + status: 'CREATING', + }) + ); + + const result = await createHttpGatewayTarget({ + region: 'us-east-1', + gatewayId: 'gw-123', + targetName: 'my-target', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + qualifier: 'DEFAULT', + }); + + expect(result.targetId).toBe('tgt-001'); + expect(result.name).toBe('my-target'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.name).toBe('my-target'); + expect(body.targetConfiguration.http.agentcoreRuntime).toEqual({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + qualifier: 'DEFAULT', + }); + expect(body.credentialProviderConfigurations).toEqual([{ credentialProviderType: 'GATEWAY_IAM_ROLE' }]); + expect(body.clientToken).toBeDefined(); + }); + + it('falls back to runtimeTargetConfiguration on ValidationException', async () => { + // First call fails with ValidationException + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + text: () => Promise.resolve('ValidationException: Unknown field agentcoreRuntime'), + }); + // Second call (fallback) succeeds + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ + targetId: 'tgt-002', + name: 'my-target', + status: 'CREATING', + }) + ); + + const result = await createHttpGatewayTarget({ + region: 'us-east-1', + gatewayId: 'gw-123', + targetName: 'my-target', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + }); + + expect(result.targetId).toBe('tgt-002'); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Second call should use runtimeTargetConfiguration + const fallbackBody = JSON.parse(mockFetch.mock.calls[1]![1].body); + expect(fallbackBody.targetConfiguration.http.runtimeTargetConfiguration).toEqual({ + arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + qualifier: 'DEFAULT', + }); + }); + + it('falls back to runtimeTargetConfiguration on 400 status', async () => { + // First call fails with 400 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + text: () => Promise.resolve('400 Bad Request'), + }); + // Second call (fallback) succeeds + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ + targetId: 'tgt-003', + name: 'my-target', + status: 'CREATING', + }) + ); + + const result = await createHttpGatewayTarget({ + region: 'us-east-1', + gatewayId: 'gw-123', + targetName: 'my-target', + runtimeArn: 'arn:runtime', + }); + + expect(result.targetId).toBe('tgt-003'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('throws on non-validation errors (no fallback)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + text: () => Promise.resolve('Internal Server Error'), + }); + + await expect( + createHttpGatewayTarget({ + region: 'us-east-1', + gatewayId: 'gw-123', + targetName: 'my-target', + runtimeArn: 'arn:runtime', + }) + ).rejects.toThrow('Failed to create target'); + + // Only one call — no fallback attempt + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('getHttpGateway', () => { + it('returns gateway details', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + gatewayId: 'gw-123', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-123', + gatewayUrl: 'https://gw-123.example.com', + name: 'my-gateway', + status: 'READY', + authorizerType: 'AWS_IAM', + roleArn: 'arn:aws:iam::123:role/GwRole', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + }) + ); + + const result = await getHttpGateway({ region: 'us-east-1', gatewayId: 'gw-123' }); + + expect(result.gatewayId).toBe('gw-123'); + expect(result.name).toBe('my-gateway'); + expect(result.status).toBe('READY'); + expect(result.gatewayUrl).toBe('https://gw-123.example.com'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/gateways/gw-123'), + expect.objectContaining({ method: 'GET' }) + ); + }); + }); + + describe('listHttpGatewayTargets', () => { + it('returns targets array', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + targets: [ + { targetId: 'tgt-1', name: 'target-1', status: 'READY' }, + { targetId: 'tgt-2', name: 'target-2', status: 'CREATING' }, + ], + }) + ); + + const result = await listHttpGatewayTargets({ + region: 'us-east-1', + gatewayId: 'gw-123', + }); + + expect(result.targets).toHaveLength(2); + expect(result.targets[0]!.targetId).toBe('tgt-1'); + expect(result.targets[0]!.name).toBe('target-1'); + expect(result.targets[1]!.targetId).toBe('tgt-2'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/gateways/gw-123/targets'), + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('handles response with items field instead of targets', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + items: [{ targetId: 'tgt-1', name: 'target-1', status: 'READY' }], + }) + ); + + const result = await listHttpGatewayTargets({ + region: 'us-east-1', + gatewayId: 'gw-123', + }); + + expect(result.targets).toHaveLength(1); + expect(result.targets[0]!.targetId).toBe('tgt-1'); + }); + }); +}); diff --git a/src/cli/aws/__tests__/agentcore-recommendation.test.ts b/src/cli/aws/__tests__/agentcore-recommendation.test.ts new file mode 100644 index 000000000..1b330cf30 --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-recommendation.test.ts @@ -0,0 +1,295 @@ +import { + deleteRecommendation, + getRecommendation, + listRecommendations, + startRecommendation, +} from '../agentcore-recommendation.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +vi.mock('../account', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + sessionToken: 'TOKEN', + }), +})); + +vi.mock('@smithy/signature-v4', () => ({ + SignatureV4: class { + // eslint-disable-next-line @typescript-eslint/require-await + async sign(request: { headers: Record }) { + return { headers: { ...request.headers, Authorization: 'signed' } }; + } + }, +})); + +vi.mock('@aws-crypto/sha256-js', () => ({ + Sha256: class {}, +})); + +vi.mock('@aws-sdk/credential-provider-node', () => ({ + defaultProvider: vi.fn(), +})); + +function mockJsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +describe('agentcore-recommendation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('startRecommendation', () => { + it('sends POST to /recommendations with correct body', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + recommendationId: 'rec-123', + recommendationArn: 'arn:rec-123', + name: 'MyRecommendation', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }) + ); + + const result = await startRecommendation({ + region: 'us-west-2', + name: 'MyRecommendation', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + recommendationConfig: { + systemPromptRecommendationConfig: { + systemPrompt: { text: 'You are a helpful agent.' }, + agentTraces: { + cloudwatchLogs: { + logGroupArns: ['arn:log-group'], + serviceNames: ['bedrock-agentcore'], + startTime: '2026-03-23T00:00:00.000Z', + endTime: '2026-03-30T00:00:00.000Z', + }, + }, + evaluationConfig: { + evaluators: [{ evaluatorArn: 'arn:aws:bedrock-agentcore:::evaluator/Builtin.Helpfulness' }], + }, + }, + }, + }); + + expect(result.recommendationId).toBe('rec-123'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/recommendations'), + expect.objectContaining({ method: 'POST' }) + ); + + const fetchCall = mockFetch.mock.calls[0]!; + const body = JSON.parse(fetchCall[1].body); + expect(body.name).toBe('MyRecommendation'); + expect(body.type).toBe('SYSTEM_PROMPT_RECOMMENDATION'); + expect(body.recommendationConfig.systemPromptRecommendationConfig).toBeDefined(); + }); + + it('omits description when not provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + recommendationId: 'r1', + recommendationArn: 'arn:1', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }) + ); + + await startRecommendation({ + region: 'us-west-2', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + recommendationConfig: { + systemPromptRecommendationConfig: { + systemPrompt: { text: '' }, + agentTraces: { + cloudwatchLogs: { + logGroupArns: [], + serviceNames: ['bedrock-agentcore'], + startTime: '2026-03-23T00:00:00.000Z', + endTime: '2026-03-30T00:00:00.000Z', + }, + }, + evaluationConfig: { + evaluators: [{ evaluatorArn: 'arn:aws:bedrock-agentcore:::evaluator/Builtin.Helpfulness' }], + }, + }, + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.description).toBeUndefined(); + }); + + it('includes description when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + recommendationId: 'r1', + recommendationArn: 'arn:1', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }) + ); + + await startRecommendation({ + region: 'us-west-2', + name: 'MyRec', + description: 'Test description', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + recommendationConfig: { + systemPromptRecommendationConfig: { + systemPrompt: { text: '' }, + agentTraces: { + cloudwatchLogs: { + logGroupArns: [], + serviceNames: ['bedrock-agentcore'], + startTime: '2026-03-23T00:00:00.000Z', + endTime: '2026-03-30T00:00:00.000Z', + }, + }, + evaluationConfig: { + evaluators: [{ evaluatorArn: 'arn:aws:bedrock-agentcore:::evaluator/Builtin.Helpfulness' }], + }, + }, + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.description).toBe('Test description'); + }); + + it('throws on non-ok response', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + text: () => Promise.resolve('Bad Request'), + }); + + await expect( + startRecommendation({ + region: 'us-west-2', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + recommendationConfig: {}, + }) + ).rejects.toThrow('Recommendation API error (400)'); + }); + }); + + describe('getRecommendation', () => { + it('sends GET to /recommendations/{id}', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + recommendationId: 'rec-123', + recommendationArn: 'arn:rec-123', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'COMPLETED', + recommendationResult: { + systemPromptRecommendationResult: { + recommendedSystemPrompt: 'Optimized prompt', + explanation: 'Made it better', + }, + }, + }) + ); + + const result = await getRecommendation({ region: 'us-west-2', recommendationId: 'rec-123' }); + + expect(result.recommendationId).toBe('rec-123'); + expect(result.name).toBe('MyRec'); + expect(result.recommendationResult?.systemPromptRecommendationResult?.recommendedSystemPrompt).toBe( + 'Optimized prompt' + ); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/recommendations/rec-123'), + expect.objectContaining({ method: 'GET' }) + ); + }); + }); + + describe('deleteRecommendation', () => { + it('sends DELETE to /recommendations/{id}', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ recommendationId: 'rec-123', status: 'DELETING' }, 200)); + + const result = await deleteRecommendation({ region: 'us-west-2', recommendationId: 'rec-123' }); + + expect(result.recommendationId).toBe('rec-123'); + expect(result.status).toBe('DELETING'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/recommendations/rec-123'), + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('throws on failure', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect(deleteRecommendation({ region: 'us-west-2', recommendationId: 'rec-123' })).rejects.toThrow( + 'Network error' + ); + }); + }); + + describe('listRecommendations', () => { + it('sends GET to /recommendations', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + recommendationSummaries: [ + { + recommendationId: 'r1', + recommendationArn: 'arn:r1', + name: 'Rec1', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'COMPLETED', + }, + { + recommendationId: 'r2', + recommendationArn: 'arn:r2', + name: 'Rec2', + type: 'TOOL_DESCRIPTION_RECOMMENDATION', + status: 'COMPLETED', + }, + ], + }) + ); + + const result = await listRecommendations({ region: 'us-west-2' }); + + expect(result.recommendationSummaries).toHaveLength(2); + expect(result.recommendationSummaries[0]!.name).toBe('Rec1'); + }); + + it('passes maxResults and nextToken as query params', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ recommendationSummaries: [] })); + + await listRecommendations({ region: 'us-west-2', maxResults: 10, nextToken: 'abc' }); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain('maxResults=10'); + expect(url).toContain('nextToken=abc'); + }); + + it('returns empty array when response has no recommendationSummaries', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({})); + + const result = await listRecommendations({ region: 'us-west-2' }); + + expect(result.recommendationSummaries).toEqual([]); + }); + }); +}); diff --git a/src/cli/aws/__tests__/agentcore.test.ts b/src/cli/aws/__tests__/agentcore.test.ts index e26e4324e..f23ff5c83 100644 --- a/src/cli/aws/__tests__/agentcore.test.ts +++ b/src/cli/aws/__tests__/agentcore.test.ts @@ -1,4 +1,4 @@ -import { extractResult, parseA2AResponse, parseSSE, parseSSELine } from '../agentcore.js'; +import { buildBearerInvokeHeaders, extractResult, parseA2AResponse, parseSSE, parseSSELine } from '../agentcore.js'; import { describe, expect, it } from 'vitest'; describe('parseSSELine', () => { @@ -176,3 +176,43 @@ describe('parseA2AResponse', () => { expect(parseA2AResponse('not json')).toBe('not json'); }); }); + +describe('buildBearerInvokeHeaders', () => { + it('includes custom headers from options.headers', () => { + const headers = buildBearerInvokeHeaders( + { + bearerToken: 'tok', + headers: { + 'x-amzn-bedrock-agentcore-runtime-custom-foo': 'bar', + 'x-amzn-bedrock-agentcore-runtime-custom-baz': 'qux', + }, + }, + 'application/json' + ); + expect(headers['x-amzn-bedrock-agentcore-runtime-custom-foo']).toBe('bar'); + expect(headers['x-amzn-bedrock-agentcore-runtime-custom-baz']).toBe('qux'); + }); + + it('sets Authorization, Content-Type, Accept, and default user ID', () => { + const headers = buildBearerInvokeHeaders({ bearerToken: 'tok' }, 'application/json'); + expect(headers.Authorization).toBe('Bearer tok'); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers.Accept).toBe('application/json'); + expect(headers['X-Amzn-Bedrock-AgentCore-Runtime-User-Id']).toBe('default-user'); + }); + + it('sets session ID header when provided', () => { + const headers = buildBearerInvokeHeaders({ bearerToken: 'tok', sessionId: 's1' }, 'application/json'); + expect(headers['X-Amzn-Bedrock-AgentCore-Runtime-Session-Id']).toBe('s1'); + }); + + it('omits session ID header when not provided', () => { + const headers = buildBearerInvokeHeaders({ bearerToken: 'tok' }, 'application/json'); + expect(headers).not.toHaveProperty('X-Amzn-Bedrock-AgentCore-Runtime-Session-Id'); + }); + + it('returns correct headers when options.headers is undefined', () => { + const headers = buildBearerInvokeHeaders({ bearerToken: 'tok' }, 'application/json'); + expect(Object.keys(headers)).toHaveLength(4); // Authorization, Content-Type, Accept, User-Id + }); +}); diff --git a/src/cli/aws/agentcore-ab-tests.ts b/src/cli/aws/agentcore-ab-tests.ts new file mode 100644 index 000000000..4bcf0ce16 --- /dev/null +++ b/src/cli/aws/agentcore-ab-tests.ts @@ -0,0 +1,360 @@ +/** + * AWS client wrappers for AB Test data plane operations. + * + * Uses the AgentCore Evaluation DataPlane API (bedrock-agentcore) + * with direct HTTP requests and SigV4 signing. + */ +import { getCredentialProvider } from './account'; +import { dnsSuffix } from './partition'; +import { Sha256 } from '@aws-crypto/sha256-js'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; +import { randomUUID } from 'node:crypto'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ABTestVariant { + name: 'C' | 'T1'; + weight: number; + variantConfiguration: { + configurationBundle?: { + bundleArn: string; + bundleVersion: string; + }; + target?: { + name: string; + }; + }; +} + +export type ABTestEvaluationConfig = + | { onlineEvaluationConfigArn: string } + | { + perVariantOnlineEvaluationConfig: { + name: 'C' | 'T1'; + onlineEvaluationConfigArn: string; + }[]; + }; + +export interface GatewayFilter { + targetPaths: string[]; +} + +export interface TrafficAllocationConfig { + routeOnHeader: { + headerName: string; + }; +} + +export interface ConfidenceInterval { + lower?: number; + upper?: number; +} + +export interface ControlStats { + treatmentName: string; + sampleSize: number; + mean: number; +} + +export interface VariantResult { + treatmentName: string; + sampleSize: number; + mean: number; + absoluteChange?: number; + percentChange?: number; + pValue?: number; + confidenceInterval?: ConfidenceInterval; + isSignificant: boolean; +} + +export interface EvaluatorMetric { + evaluatorArn: string; + controlStats: ControlStats; + variantResults: VariantResult[]; +} + +export interface ABTestResults { + analysisTimestamp?: string; + evaluatorMetrics: EvaluatorMetric[]; +} + +// ── Create ────────────────────────────────────────────────────────────────── + +export interface CreateABTestOptions { + region: string; + name: string; + description?: string; + gatewayArn: string; + roleArn: string; + variants: ABTestVariant[]; + evaluationConfig: ABTestEvaluationConfig; + gatewayFilter?: GatewayFilter; + trafficAllocationConfig?: TrafficAllocationConfig; + maxDurationDays?: number; + enableOnCreate?: boolean; +} + +export interface CreateABTestResult { + abTestId: string; + abTestArn: string; + name?: string; + status: string; + executionStatus: string; + createdAt: string; +} + +// ── Get ───────────────────────────────────────────────────────────────────── + +export interface GetABTestOptions { + region: string; + abTestId: string; +} + +export interface GetABTestResult { + abTestId: string; + abTestArn: string; + name: string; + description?: string; + status: string; + executionStatus: string; + gatewayArn: string; + roleArn: string; + variants: ABTestVariant[]; + evaluationConfig: ABTestEvaluationConfig; + trafficAllocationConfig?: TrafficAllocationConfig; + maxDurationDays?: number; + currentRunId?: string; + stopReason?: string; + failureReason?: string; + startedAt?: string; + stoppedAt?: string; + maxDurationExpiresAt?: string; + createdAt: string; + updatedAt: string; + results?: ABTestResults; +} + +// ── Update ────────────────────────────────────────────────────────────────── + +export interface UpdateABTestOptions { + region: string; + abTestId: string; + name?: string; + description?: string; + variants?: ABTestVariant[]; + trafficAllocationConfig?: TrafficAllocationConfig; + evaluationConfig?: ABTestEvaluationConfig; + maxDurationDays?: number; + executionStatus?: 'PAUSED' | 'RUNNING' | 'STOPPED'; + roleArn?: string; +} + +export interface UpdateABTestResult { + abTestId: string; + abTestArn: string; + status: string; + executionStatus: string; + failureReason?: string; + updatedAt: string; +} + +// ── Delete ────────────────────────────────────────────────────────────────── + +export interface DeleteABTestOptions { + region: string; + abTestId: string; +} + +// ── List ──────────────────────────────────────────────────────────────────── + +export interface ListABTestsOptions { + region: string; + maxResults?: number; + nextToken?: string; +} + +export interface ABTestSummary { + abTestId: string; + abTestArn: string; + name: string; + description?: string; + status: string; + executionStatus: string; + gatewayArn?: string; + createdAt: string; + updatedAt: string; +} + +export interface ListABTestsResult { + abTests: ABTestSummary[]; + nextToken?: string; +} + +// ============================================================================ +// HTTP signing helpers +// ============================================================================ + +function getDataPlaneEndpoint(region: string): string { + const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + if (stage === 'beta') return `https://beta.${region}.elcapdp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapdp.genesis-primitives.aws.dev`; + return `https://bedrock-agentcore.${region}.${dnsSuffix(region)}`; +} + +async function signedRequestToEndpoint( + endpoint: string, + options: { + region: string; + method: string; + path: string; + body?: string; + } +): Promise { + const { region, method, path, body } = options; + const url = new URL(path, endpoint); + + const query: Record = {}; + url.searchParams.forEach((value, key) => { + query[key] = value; + }); + + const request = new HttpRequest({ + method, + protocol: 'https:', + hostname: url.hostname, + path: url.pathname, + ...(Object.keys(query).length > 0 && { query }), + headers: { + 'Content-Type': 'application/json', + host: url.hostname, + }, + ...(body && { body }), + }); + + const credentials = getCredentialProvider() ?? defaultProvider(); + const service = 'bedrock-agentcore'; + const signer = new SignatureV4({ + service, + region, + credentials, + sha256: Sha256, + }); + + const signedReq = await signer.sign(request); + + const response = await fetch(`${endpoint}${path}`, { + method, + headers: signedReq.headers as Record, + ...(body && { body }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`ABTest API error (${response.status}): ${errorBody}`); + } + + if (response.status === 204) return {}; + return response.json(); +} + +/** Data plane request — used for GetABTest (includes results/metrics). */ +async function dpRequest(options: { region: string; method: string; path: string; body?: string }): Promise { + return signedRequestToEndpoint(getDataPlaneEndpoint(options.region), options); +} + +// ============================================================================ +// Control Plane Operations (CRUD) +// ============================================================================ + +export async function createABTest(options: CreateABTestOptions): Promise { + const body = JSON.stringify({ + name: options.name, + clientToken: randomUUID(), + gatewayArn: options.gatewayArn, + roleArn: options.roleArn, + variants: options.variants, + evaluationConfig: options.evaluationConfig, + ...(options.description && { description: options.description }), + ...(options.gatewayFilter && { gatewayFilter: options.gatewayFilter }), + ...(options.trafficAllocationConfig && { trafficAllocationConfig: options.trafficAllocationConfig }), + ...(options.maxDurationDays !== undefined && { maxDurationDays: options.maxDurationDays }), + ...(options.enableOnCreate !== undefined && { enableOnCreate: options.enableOnCreate }), + }); + + const result = await dpRequest({ + region: options.region, + method: 'POST', + path: '/ab-tests', + body, + }); + + return result as CreateABTestResult; +} + +export async function getABTest(options: GetABTestOptions): Promise { + // Data plane includes results/metrics in the response + const data = await dpRequest({ + region: options.region, + method: 'GET', + path: `/ab-tests/${options.abTestId}`, + }); + + return data as GetABTestResult; +} + +export async function updateABTest(options: UpdateABTestOptions): Promise { + const body: Record = { clientToken: randomUUID() }; + if (options.name !== undefined) body.name = options.name; + if (options.description !== undefined) body.description = options.description; + if (options.variants !== undefined) body.variants = options.variants; + if (options.trafficAllocationConfig !== undefined) body.trafficAllocationConfig = options.trafficAllocationConfig; + if (options.evaluationConfig !== undefined) body.evaluationConfig = options.evaluationConfig; + if (options.maxDurationDays !== undefined) body.maxDurationDays = options.maxDurationDays; + if (options.executionStatus !== undefined) body.executionStatus = options.executionStatus; + if (options.roleArn !== undefined) body.roleArn = options.roleArn; + + const data = await dpRequest({ + region: options.region, + method: 'PUT', + path: `/ab-tests/${options.abTestId}`, + body: JSON.stringify(body), + }); + + return data as UpdateABTestResult; +} + +export async function deleteABTest(options: DeleteABTestOptions): Promise<{ success: boolean; error?: string }> { + try { + await dpRequest({ + region: options.region, + method: 'DELETE', + path: `/ab-tests/${options.abTestId}`, + }); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function listABTests(options: ListABTestsOptions): Promise { + const params = new URLSearchParams(); + if (options.maxResults) params.set('maxResults', String(options.maxResults)); + if (options.nextToken) params.set('nextToken', options.nextToken); + const query = params.toString(); + + const data = await dpRequest({ + region: options.region, + method: 'GET', + path: `/ab-tests${query ? `?${query}` : ''}`, + }); + + const result = data as ListABTestsResult; + return { + abTests: result.abTests ?? [], + nextToken: result.nextToken, + }; +} diff --git a/src/cli/aws/agentcore-batch-evaluation.ts b/src/cli/aws/agentcore-batch-evaluation.ts new file mode 100644 index 000000000..9b0923753 --- /dev/null +++ b/src/cli/aws/agentcore-batch-evaluation.ts @@ -0,0 +1,411 @@ +/** + * AWS client wrappers for BatchEvaluation operations. + * + * The BatchEvaluation API is a flat, stateless model — no persistent "job" resource. + * Each batch evaluation is started, polled, and optionally stopped. + * + * Endpoints: + * POST /evaluations/batch-evaluate → StartBatchEvaluation + * GET /evaluations/batch-evaluate/{batchEvaluationId} → GetBatchEvaluation + * GET /evaluations/batch-evaluate → ListBatchEvaluations + * POST /evaluations/batch-evaluate/{batchEvaluationId}/stop → StopBatchEvaluation + * DELETE /evaluations/batch-evaluate/{batchEvaluationId} → DeleteBatchEvaluation + * + * Uses direct HTTP requests with SigV4 signing (service: bedrock-agentcore). + */ +import { getCredentialProvider } from './account'; +import { dnsSuffix } from './partition'; +import { Sha256 } from '@aws-crypto/sha256-js'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface SessionFilterConfig { + startTime?: string; + endTime?: string; +} + +export interface CloudWatchFilterConfig { + sessionIds?: string[]; + timeRange?: SessionFilterConfig; +} + +export interface CloudWatchLogsSource { + serviceNames: string[]; + logGroupNames: string[]; + filterConfig?: CloudWatchFilterConfig; +} + +export interface DataSourceConfig { + cloudWatchLogs?: CloudWatchLogsSource; + onlineEvaluationConfigSource?: Record; +} + +export interface Evaluator { + evaluatorId: string; +} + +export interface GroundTruthAssertion { + text: string; +} + +export interface GroundTruthTurnInput { + prompt: string; +} + +export interface GroundTruthTurnExpectedResponse { + text: string; +} + +export interface GroundTruthTurn { + input: GroundTruthTurnInput; + expectedResponse: GroundTruthTurnExpectedResponse; +} + +export interface ExpectedTrajectory { + toolNames: string[]; +} + +export interface InlineGroundTruth { + assertions?: GroundTruthAssertion[]; + expectedTrajectory?: ExpectedTrajectory; + turns?: GroundTruthTurn[]; +} + +export interface GroundTruth { + inline: InlineGroundTruth; +} + +export interface SessionMetadataEntry { + sessionId: string; + testScenarioId?: string; + groundTruth?: GroundTruth; + metadata?: Record; +} + +export interface EvaluationMetadata { + sessionMetadata?: SessionMetadataEntry[]; +} + +export interface StartBatchEvaluationOptions { + region: string; + name: string; + evaluators: Evaluator[]; + dataSourceConfig: DataSourceConfig; + evaluationMetadata?: EvaluationMetadata; + description?: string; + clientToken?: string; +} + +export interface StartBatchEvaluationResult { + batchEvaluationId: string; + batchEvaluationArn: string; + name: string; + status: string; + createdAt?: string; +} + +export interface GetBatchEvaluationOptions { + region: string; + batchEvaluationId: string; +} + +export interface CloudWatchOutputConfig { + logGroupName: string; + logStreamName: string; +} + +export interface OutputConfig { + cloudWatchConfig?: CloudWatchOutputConfig; +} + +export interface EvaluatorSummary { + evaluatorId: string; + statistics?: { + averageScore?: number; + averageTokenUsage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + }; + }; + totalEvaluated?: number; + totalFailed?: number; +} + +export interface EvaluationResults { + evaluatorSummaries?: EvaluatorSummary[]; + numberOfSessionsCompleted?: number; + numberOfSessionsFailed?: number; + numberOfSessionsInProgress?: number; + totalNumberOfSessions?: number; + numberOfSessionsIgnored?: number; +} + +export interface GetBatchEvaluationResult { + batchEvaluationId: string; + batchEvaluationArn: string; + name: string; + status: string; + createdAt?: string; + updatedAt?: string; + evaluators?: Evaluator[]; + dataSourceConfig?: DataSourceConfig; + outputConfig?: OutputConfig; + evaluationResults?: EvaluationResults; + errorDetails?: string[]; + description?: string; +} + +export interface BatchEvaluationResultEntry { + evaluatorId: string; + score?: number; + label?: string; + explanation?: string; + error?: string; +} + +export interface ListBatchEvaluationsOptions { + region: string; + maxResults?: number; + nextToken?: string; +} + +export interface BatchEvaluationSummary { + batchEvaluationId: string; + batchEvaluationArn: string; + name: string; + status: string; + createdAt?: string; + description?: string; + evaluators?: Evaluator[]; + evaluationResults?: EvaluationResults; + errorDetails?: string[]; +} + +export interface ListBatchEvaluationsResult { + batchEvaluations: BatchEvaluationSummary[]; + nextToken?: string; +} + +export interface StopBatchEvaluationOptions { + region: string; + batchEvaluationId: string; +} + +export interface StopBatchEvaluationResult { + batchEvaluationId: string; + batchEvaluationArn: string; + status: string; + description?: string; +} + +export interface DeleteBatchEvaluationOptions { + region: string; + batchEvaluationId: string; +} + +export interface DeleteBatchEvaluationResult { + batchEvaluationId: string; + batchEvaluationArn: string; + status: string; +} + +// ============================================================================ +// HTTP signing helper +// ============================================================================ + +function getEndpoint(region: string): string { + const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + if (stage === 'beta') return `https://beta.${region}.elcapdp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapdp.genesis-primitives.aws.dev`; + return `https://bedrock-agentcore.${region}.${dnsSuffix(region)}`; +} + +async function signedRequest(options: { + region: string; + method: string; + path: string; + body?: string; +}): Promise<{ data: unknown; status: number }> { + const { region, method, path, body } = options; + const endpoint = getEndpoint(region); + const url = new URL(path, endpoint); + + const request = new HttpRequest({ + method, + protocol: 'https:', + hostname: url.hostname, + path: url.pathname + url.search, + headers: { + 'Content-Type': 'application/json', + host: url.hostname, + }, + ...(body && { body }), + }); + + const credentials = getCredentialProvider() ?? defaultProvider(); + const signer = new SignatureV4({ + service: 'bedrock-agentcore', + region, + credentials, + sha256: Sha256, + }); + + const signedReq = await signer.sign(request); + + const response = await fetch(`${endpoint}${url.pathname}${url.search}`, { + method, + headers: signedReq.headers as Record, + ...(body && { body }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`BatchEvaluation API error (${response.status}): ${errorBody}`); + } + + if (response.status === 204) return { data: {}, status: 204 }; + return { data: await response.json(), status: response.status }; +} + +// ============================================================================ +// API Operations +// ============================================================================ + +/** + * Start a batch evaluation (async — returns immediately with an ID to poll). + */ +export async function startBatchEvaluation(options: StartBatchEvaluationOptions): Promise { + const body: Record = { + batchEvaluationName: options.name, + evaluators: options.evaluators, + dataSourceConfig: options.dataSourceConfig, + }; + if (options.evaluationMetadata) { + body.evaluationMetadata = options.evaluationMetadata; + } + if (options.description) { + body.description = options.description; + } + if (options.clientToken) { + body.clientToken = options.clientToken; + } + + const { data } = await signedRequest({ + region: options.region, + method: 'POST', + path: '/evaluations/batch-evaluate', + body: JSON.stringify(body), + }); + + const raw = data as Record; + return { + batchEvaluationId: (raw.batchEvaluationId ?? '') as string, + batchEvaluationArn: (raw.batchEvaluationArn ?? '') as string, + name: (raw.batchEvaluationName ?? '') as string, + status: (raw.status ?? '') as string, + createdAt: raw.createdAt as string | undefined, + }; +} + +/** + * Get status and results of a batch evaluation. + */ +export async function getBatchEvaluation(options: GetBatchEvaluationOptions): Promise { + const { data } = await signedRequest({ + region: options.region, + method: 'GET', + path: `/evaluations/batch-evaluate/${options.batchEvaluationId}`, + }); + + const raw = data as Record; + return { + batchEvaluationId: (raw.batchEvaluationId ?? '') as string, + batchEvaluationArn: (raw.batchEvaluationArn ?? '') as string, + name: (raw.batchEvaluationName ?? '') as string, + status: (raw.status ?? '') as string, + createdAt: raw.createdAt as string | undefined, + updatedAt: raw.updatedAt as string | undefined, + evaluators: raw.evaluators as Evaluator[] | undefined, + dataSourceConfig: raw.dataSourceConfig as DataSourceConfig | undefined, + outputConfig: raw.outputConfig as OutputConfig | undefined, + evaluationResults: raw.evaluationResults as EvaluationResults | undefined, + errorDetails: raw.errorDetails as string[] | undefined, + description: raw.description as string | undefined, + }; +} + +/** + * List batch evaluations. + */ +export async function listBatchEvaluations(options: ListBatchEvaluationsOptions): Promise { + const params = new URLSearchParams(); + if (options.maxResults) params.set('maxResults', String(options.maxResults)); + if (options.nextToken) params.set('nextToken', options.nextToken); + + const query = params.toString(); + const path = `/evaluations/batch-evaluate${query ? `?${query}` : ''}`; + + const { data } = await signedRequest({ + region: options.region, + method: 'GET', + path, + }); + + const result = data as ListBatchEvaluationsResult; + return { + batchEvaluations: result.batchEvaluations ?? [], + nextToken: result.nextToken, + }; +} + +/** + * Stop a running batch evaluation. + */ +export async function stopBatchEvaluation(options: StopBatchEvaluationOptions): Promise { + const { data } = await signedRequest({ + region: options.region, + method: 'POST', + path: `/evaluations/batch-evaluate/${options.batchEvaluationId}/stop`, + }); + + const raw = data as Record; + return { + batchEvaluationId: (raw.batchEvaluationId ?? '') as string, + batchEvaluationArn: (raw.batchEvaluationArn ?? '') as string, + status: (raw.status ?? '') as string, + description: raw.description as string | undefined, + }; +} + +/** + * Delete a batch evaluation. + */ +export async function deleteBatchEvaluation( + options: DeleteBatchEvaluationOptions +): Promise { + const { data } = await signedRequest({ + region: options.region, + method: 'DELETE', + path: `/evaluations/batch-evaluate/${options.batchEvaluationId}`, + }); + + const raw = data as Record; + return { + batchEvaluationId: (raw.batchEvaluationId ?? '') as string, + batchEvaluationArn: (raw.batchEvaluationArn ?? '') as string, + status: (raw.status ?? '') as string, + }; +} + +/** + * Generate a client token for idempotency. + */ +export function generateClientToken(): string { + return crypto.randomUUID(); +} diff --git a/src/cli/aws/agentcore-config-bundles.ts b/src/cli/aws/agentcore-config-bundles.ts new file mode 100644 index 000000000..d890d95df --- /dev/null +++ b/src/cli/aws/agentcore-config-bundles.ts @@ -0,0 +1,368 @@ +/** + * AWS client wrappers for Configuration Bundle control plane operations. + * + * NOTE: The ConfigurationBundle API is not yet available in the + * @aws-sdk/client-bedrock-agentcore-control SDK. These wrappers use + * direct HTTP requests with SigV4 signing as an interim solution. + * When the SDK adds ConfigurationBundle commands, migrate to the SDK client. + */ +import { getCredentialProvider } from './account'; +import { dnsSuffix } from './partition'; +import { Sha256 } from '@aws-crypto/sha256-js'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; +import { randomUUID } from 'node:crypto'; + +// ============================================================================ +// Types +// ============================================================================ + +/** Freeform configuration for a component within a bundle. */ +export interface ComponentConfiguration { + configuration: Record; +} + +/** Map of component identifier (ARN) to its configuration. */ +export type ComponentConfigurationMap = Record; + +/** Version lineage metadata for git-like versioning. */ +export interface VersionLineageMetadata { + parentVersionIds?: string[]; + branchName?: string; + createdBy?: { name: string; arn?: string }; + commitMessage?: string; +} + +// ── Create ────────────────────────────────────────────────────────────────── + +export interface CreateConfigurationBundleOptions { + region: string; + bundleName: string; + description?: string; + components: ComponentConfigurationMap; + branchName?: string; + commitMessage?: string; + createdBy?: { name: string; arn?: string }; +} + +export interface CreateConfigurationBundleResult { + bundleArn: string; + bundleId: string; + versionId: string; + createdAt: string; +} + +// ── Get ───────────────────────────────────────────────────────────────────── + +export interface GetConfigurationBundleOptions { + region: string; + bundleId: string; + branchName?: string; +} + +export interface GetConfigurationBundleResult { + bundleArn: string; + bundleId: string; + bundleName: string; + description?: string; + versionId: string; + components: ComponentConfigurationMap; + lineageMetadata?: VersionLineageMetadata; + createdAt: string; + updatedAt: string; +} + +// ── Update ────────────────────────────────────────────────────────────────── + +export interface UpdateConfigurationBundleOptions { + region: string; + bundleId: string; + bundleName?: string; + description?: string; + components?: ComponentConfigurationMap; + parentVersionIds?: string[]; + branchName?: string; + commitMessage?: string; + createdBy?: { name: string; arn?: string }; +} + +export interface UpdateConfigurationBundleResult { + bundleArn: string; + bundleId: string; + versionId: string; + updatedAt: string; +} + +// ── Delete ────────────────────────────────────────────────────────────────── + +export interface DeleteConfigurationBundleOptions { + region: string; + bundleId: string; +} + +// ── List ──────────────────────────────────────────────────────────────────── + +export interface ListConfigurationBundlesOptions { + region: string; + maxResults?: number; + nextToken?: string; +} + +export interface ConfigurationBundleSummary { + bundleArn: string; + bundleId: string; + bundleName: string; + description?: string; +} + +export interface ListConfigurationBundlesResult { + bundles: ConfigurationBundleSummary[]; + nextToken?: string; +} + +// ── Get Version ───────────────────────────────────────────────────────────── + +export interface GetConfigurationBundleVersionOptions { + region: string; + bundleId: string; + versionId: string; +} + +export interface GetConfigurationBundleVersionResult { + bundleArn: string; + bundleId: string; + bundleName: string; + description?: string; + versionId: string; + components: ComponentConfigurationMap; + lineageMetadata?: VersionLineageMetadata; + createdAt: string; + versionCreatedAt: string; +} + +// ── List Versions ─────────────────────────────────────────────────────────── + +export interface ListConfigurationBundleVersionsFilter { + branchName?: string; + latestPerBranch?: boolean; + createdByName?: string; +} + +export interface ListConfigurationBundleVersionsOptions { + region: string; + bundleId: string; + maxResults?: number; + nextToken?: string; + filter?: ListConfigurationBundleVersionsFilter; +} + +export interface ConfigurationBundleVersionSummary { + bundleArn: string; + bundleId: string; + versionId: string; + lineageMetadata?: VersionLineageMetadata; + versionCreatedAt: string; +} + +export interface ListConfigurationBundleVersionsResult { + versions: ConfigurationBundleVersionSummary[]; + nextToken?: string; +} + +// ============================================================================ +// HTTP signing helper +// ============================================================================ + +// TODO: Remove beta/gamma endpoints before GA merge +function getControlPlaneEndpoint(region: string): string { + const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + if (stage === 'beta') return `https://beta.${region}.elcapcp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapcp.genesis-primitives.aws.dev`; + return `https://bedrock-agentcore-control.${region}.${dnsSuffix(region)}`; +} + +async function signedRequest(options: { + region: string; + method: string; + path: string; + body?: string; +}): Promise { + const { region, method, path, body } = options; + const endpoint = getControlPlaneEndpoint(region); + const url = new URL(path, endpoint); + + const query: Record = {}; + url.searchParams.forEach((value, key) => { + query[key] = value; + }); + + const request = new HttpRequest({ + method, + protocol: 'https:', + hostname: url.hostname, + path: url.pathname, + ...(Object.keys(query).length > 0 && { query }), + headers: { + 'Content-Type': 'application/json', + host: url.hostname, + }, + ...(body && { body }), + }); + + const credentials = getCredentialProvider() ?? defaultProvider(); + const service = 'bedrock-agentcore'; + const signer = new SignatureV4({ + service, + region, + credentials, + sha256: Sha256, + }); + + const signedReq = await signer.sign(request); + + const response = await fetch(`${endpoint}${path}`, { + method, + headers: signedReq.headers as Record, + ...(body && { body }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`ConfigurationBundle API error (${response.status}): ${errorBody}`); + } + + if (response.status === 204) return {}; + return response.json(); +} + +// ============================================================================ +// Control Plane Operations +// ============================================================================ + +export async function createConfigurationBundle( + options: CreateConfigurationBundleOptions +): Promise { + const body = JSON.stringify({ + bundleName: options.bundleName, + clientToken: randomUUID(), + ...(options.description && { description: options.description }), + components: options.components, + ...(options.branchName && { branchName: options.branchName }), + ...(options.commitMessage && { commitMessage: options.commitMessage }), + ...(options.createdBy && { createdBy: options.createdBy }), + }); + + const result = await signedRequest({ + region: options.region, + method: 'POST', + path: '/configuration-bundles/create', + body, + }); + + return result as CreateConfigurationBundleResult; +} + +export async function getConfigurationBundle( + options: GetConfigurationBundleOptions +): Promise { + const params = new URLSearchParams(); + if (options.branchName) params.set('branchName', options.branchName); + const query = params.toString(); + const path = `/configuration-bundles/${options.bundleId}${query ? `?${query}` : ''}`; + + const data = await signedRequest({ + region: options.region, + method: 'GET', + path, + }); + + return data as GetConfigurationBundleResult; +} + +export async function updateConfigurationBundle( + options: UpdateConfigurationBundleOptions +): Promise { + const body: Record = { clientToken: randomUUID() }; + if (options.bundleName !== undefined) body.bundleName = options.bundleName; + if (options.description !== undefined) body.description = options.description; + if (options.components !== undefined) body.components = options.components; + if (options.parentVersionIds !== undefined) body.parentVersionIds = options.parentVersionIds; + if (options.branchName !== undefined) body.branchName = options.branchName; + if (options.commitMessage !== undefined) body.commitMessage = options.commitMessage; + if (options.createdBy !== undefined) body.createdBy = options.createdBy; + + const data = await signedRequest({ + region: options.region, + method: 'PUT', + path: `/configuration-bundles/${options.bundleId}`, + body: JSON.stringify(body), + }); + + return data as UpdateConfigurationBundleResult; +} + +export async function deleteConfigurationBundle(options: DeleteConfigurationBundleOptions): Promise { + await signedRequest({ + region: options.region, + method: 'DELETE', + path: `/configuration-bundles/${options.bundleId}`, + }); +} + +export async function listConfigurationBundles( + options: ListConfigurationBundlesOptions +): Promise { + const params = new URLSearchParams(); + if (options.maxResults) params.set('maxResults', String(options.maxResults)); + if (options.nextToken) params.set('nextToken', options.nextToken); + const query = params.toString(); + + const data = await signedRequest({ + region: options.region, + method: 'POST', + path: `/configuration-bundles${query ? `?${query}` : ''}`, + }); + + const result = data as ListConfigurationBundlesResult; + return { + bundles: result.bundles ?? [], + nextToken: result.nextToken, + }; +} + +export async function getConfigurationBundleVersion( + options: GetConfigurationBundleVersionOptions +): Promise { + const data = await signedRequest({ + region: options.region, + method: 'GET', + path: `/configuration-bundles/${options.bundleId}/versions/${options.versionId}`, + }); + + return data as GetConfigurationBundleVersionResult; +} + +export async function listConfigurationBundleVersions( + options: ListConfigurationBundleVersionsOptions +): Promise { + const params = new URLSearchParams(); + if (options.maxResults) params.set('maxResults', String(options.maxResults)); + if (options.nextToken) params.set('nextToken', options.nextToken); + const query = params.toString(); + + const body = options.filter ? JSON.stringify({ filter: options.filter }) : undefined; + + const data = await signedRequest({ + region: options.region, + method: 'POST', + path: `/configuration-bundles/${options.bundleId}/versions${query ? `?${query}` : ''}`, + body, + }); + + const result = data as ListConfigurationBundleVersionsResult; + return { + versions: result.versions ?? [], + nextToken: result.nextToken, + }; +} diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index d44c6473f..162c2b3a6 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -4,10 +4,14 @@ import { BedrockAgentCoreControlClient, GetAgentRuntimeCommand, GetEvaluatorCommand, + GetGatewayCommand, + GetGatewayTargetCommand, GetMemoryCommand, GetOnlineEvaluationConfigCommand, ListAgentRuntimesCommand, ListEvaluatorsCommand, + ListGatewayTargetsCommand, + ListGatewaysCommand, ListMemoriesCommand, ListOnlineEvaluationConfigsCommand, ListTagsForResourceCommand, @@ -781,3 +785,361 @@ export async function getOnlineEvaluationConfig( evaluatorIds, }; } + +// ============================================================================ +// Gateways — List & Get +// ============================================================================ + +export interface GatewaySummary { + gatewayId: string; + name: string; + status: string; + description?: string; + authorizerType: string; +} + +export interface GatewayDetail { + gatewayId: string; + gatewayArn: string; + gatewayUrl?: string; + name: string; + status: string; + description?: string; + authorizerType: string; + roleArn?: string; + authorizerConfiguration?: { + customJwtAuthorizer?: { + discoveryUrl: string; + allowedAudience?: string[]; + allowedClients?: string[]; + allowedScopes?: string[]; + customClaims?: { + inboundTokenClaimName: string; + inboundTokenClaimValueType: string; + authorizingClaimMatchValue: { + claimMatchValue: { matchValueString?: string; matchValueStringList?: string[] }; + claimMatchOperator: string; + }; + }[]; + }; + }; + protocolConfiguration?: { + mcp?: { searchType?: string }; + }; + exceptionLevel?: string; + policyEngineConfiguration?: { + arn: string; + mode: string; + }; + tags?: Record; +} + +export interface ListGatewaysResult { + gateways: GatewaySummary[]; + nextToken?: string; +} + +export async function listGatewaysPage( + options: { region: string; maxResults?: number; nextToken?: string }, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); + + const command = new ListGatewaysCommand({ + maxResults: options.maxResults, + nextToken: options.nextToken, + }); + + const response = await resolvedClient.send(command); + + return { + gateways: (response.items ?? []).map(g => ({ + gatewayId: g.gatewayId ?? '', + name: g.name ?? '', + status: g.status ?? 'UNKNOWN', + description: g.description, + authorizerType: g.authorizerType ?? 'NONE', + })), + nextToken: response.nextToken, + }; +} + +/** + * List all Gateways in the given region, paginating through all pages. + */ +export async function listAllGateways(options: { region: string }): Promise { + return paginateAll(options.region, async (opts, client) => { + const result = await listGatewaysPage(opts, client); + return { items: result.gateways, nextToken: result.nextToken }; + }); +} + +/** + * Get full details of a Gateway by ID. + */ +export async function getGatewayDetail(options: { region: string; gatewayId: string }): Promise { + const client = createControlClient(options.region); + + const command = new GetGatewayCommand({ + gatewayIdentifier: options.gatewayId, + }); + + const response = await client.send(command); + + let authorizerConfiguration: GatewayDetail['authorizerConfiguration']; + if (response.authorizerConfiguration && 'customJWTAuthorizer' in response.authorizerConfiguration) { + const jwt = response.authorizerConfiguration.customJWTAuthorizer; + if (jwt) { + authorizerConfiguration = { + customJwtAuthorizer: { + discoveryUrl: jwt.discoveryUrl ?? '', + allowedAudience: jwt.allowedAudience, + allowedClients: jwt.allowedClients, + allowedScopes: jwt.allowedScopes, + customClaims: jwt.customClaims?.map(c => ({ + inboundTokenClaimName: c.inboundTokenClaimName ?? '', + inboundTokenClaimValueType: c.inboundTokenClaimValueType ?? 'STRING', + authorizingClaimMatchValue: { + claimMatchValue: { + matchValueString: + c.authorizingClaimMatchValue?.claimMatchValue && + 'matchValueString' in c.authorizingClaimMatchValue.claimMatchValue + ? c.authorizingClaimMatchValue.claimMatchValue.matchValueString + : undefined, + matchValueStringList: + c.authorizingClaimMatchValue?.claimMatchValue && + 'matchValueStringList' in c.authorizingClaimMatchValue.claimMatchValue + ? c.authorizingClaimMatchValue.claimMatchValue.matchValueStringList + : undefined, + }, + claimMatchOperator: c.authorizingClaimMatchValue?.claimMatchOperator ?? 'EQUALS', + }, + })), + }, + }; + } + } + + let protocolConfiguration: GatewayDetail['protocolConfiguration']; + if (response.protocolConfiguration && 'mcp' in response.protocolConfiguration) { + protocolConfiguration = { + mcp: { searchType: response.protocolConfiguration.mcp?.searchType }, + }; + } + + const tags = await fetchTags(client, response.gatewayArn, 'gateway'); + + return { + gatewayId: response.gatewayId ?? '', + gatewayArn: response.gatewayArn ?? '', + gatewayUrl: response.gatewayUrl, + name: response.name ?? '', + status: response.status ?? 'UNKNOWN', + description: response.description, + authorizerType: response.authorizerType ?? 'NONE', + roleArn: response.roleArn, + authorizerConfiguration, + protocolConfiguration, + exceptionLevel: response.exceptionLevel, + policyEngineConfiguration: response.policyEngineConfiguration + ? { arn: response.policyEngineConfiguration.arn ?? '', mode: response.policyEngineConfiguration.mode ?? '' } + : undefined, + tags, + }; +} + +// ============================================================================ +// Gateway Targets — List & Get +// ============================================================================ + +export interface GatewayTargetSummary { + targetId: string; + name: string; + status: string; + description?: string; +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface GatewayTargetDetail { + targetId: string; + name: string; + status: string; + description?: string; + targetConfiguration?: { + mcp?: { + mcpServer?: { endpoint: string }; + apiGateway?: { + restApiId: string; + stage: string; + apiGatewayToolConfiguration?: { + toolFilters?: { filterPath: string; methods: string[] }[]; + toolOverrides?: { name: string; path: string; method: string; description?: string }[]; + }; + }; + openApiSchema?: { s3?: { uri: string; bucketOwnerAccountId?: string }; inlinePayload?: string }; + smithyModel?: { s3?: { uri: string; bucketOwnerAccountId?: string }; inlinePayload?: string }; + lambda?: { lambdaArn: string; toolSchema?: any }; + }; + }; + credentialProviderConfigurations?: { + credentialProviderType: string; + credentialProvider?: { + oauthCredentialProvider?: { providerArn: string; scopes?: string[] }; + apiKeyCredentialProvider?: { providerArn: string }; + }; + }[]; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +export interface ListGatewayTargetsResult { + targets: GatewayTargetSummary[]; + nextToken?: string; +} + +export async function listGatewayTargetsPage( + options: { region: string; gatewayId: string; maxResults?: number; nextToken?: string }, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); + + const command = new ListGatewayTargetsCommand({ + gatewayIdentifier: options.gatewayId, + maxResults: options.maxResults, + nextToken: options.nextToken, + }); + + const response = await resolvedClient.send(command); + + return { + targets: (response.items ?? []).map(t => ({ + targetId: t.targetId ?? '', + name: t.name ?? '', + status: t.status ?? 'UNKNOWN', + description: t.description, + })), + nextToken: response.nextToken, + }; +} + +/** + * List all targets for a Gateway, paginating through all pages. + */ +export async function listAllGatewayTargets(options: { + region: string; + gatewayId: string; +}): Promise { + const client = createControlClient(options.region); + const items: GatewayTargetSummary[] = []; + let nextToken: string | undefined; + + do { + const result = await listGatewayTargetsPage( + { region: options.region, gatewayId: options.gatewayId, maxResults: 100, nextToken }, + client + ); + items.push(...result.targets); + nextToken = result.nextToken; + } while (nextToken); + + return items; +} + +/** + * Get full details of a Gateway Target by gateway ID and target ID. + */ +export async function getGatewayTargetDetail(options: { + region: string; + gatewayId: string; + targetId: string; +}): Promise { + const client = createControlClient(options.region); + + const command = new GetGatewayTargetCommand({ + gatewayIdentifier: options.gatewayId, + targetId: options.targetId, + }); + + const response = await client.send(command); + + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ + let targetConfiguration: GatewayTargetDetail['targetConfiguration']; + if (response.targetConfiguration && 'mcp' in response.targetConfiguration) { + const mcp = response.targetConfiguration.mcp as any; + targetConfiguration = { mcp: {} }; + + if (mcp?.mcpServer) { + targetConfiguration.mcp!.mcpServer = { endpoint: mcp.mcpServer.endpoint ?? '' }; + } + if (mcp?.apiGateway) { + targetConfiguration.mcp!.apiGateway = { + restApiId: mcp.apiGateway.restApiId ?? '', + stage: mcp.apiGateway.stage ?? '', + apiGatewayToolConfiguration: mcp.apiGateway.apiGatewayToolConfiguration + ? { + toolFilters: mcp.apiGateway.apiGatewayToolConfiguration.toolFilters?.map((f: any) => ({ + filterPath: f.filterPath ?? '', + methods: f.methods ?? [], + })), + toolOverrides: mcp.apiGateway.apiGatewayToolConfiguration.toolOverrides?.map((o: any) => ({ + name: o.name ?? '', + path: o.path ?? '', + method: o.method ?? '', + description: o.description, + })), + } + : undefined, + }; + } + if (mcp?.openApiSchema) { + targetConfiguration.mcp!.openApiSchema = { + s3: mcp.openApiSchema.s3 + ? { uri: mcp.openApiSchema.s3.uri ?? '', bucketOwnerAccountId: mcp.openApiSchema.s3.bucketOwnerAccountId } + : undefined, + inlinePayload: mcp.openApiSchema.inlinePayload, + }; + } + if (mcp?.smithyModel) { + targetConfiguration.mcp!.smithyModel = { + s3: mcp.smithyModel.s3 + ? { uri: mcp.smithyModel.s3.uri ?? '', bucketOwnerAccountId: mcp.smithyModel.s3.bucketOwnerAccountId } + : undefined, + inlinePayload: mcp.smithyModel.inlinePayload, + }; + } + if (mcp?.lambda) { + targetConfiguration.mcp!.lambda = { + lambdaArn: mcp.lambda.lambdaArn ?? '', + toolSchema: mcp.lambda.toolSchema, + }; + } + } + + const credentialProviderConfigurations: GatewayTargetDetail['credentialProviderConfigurations'] = ( + response.credentialProviderConfigurations ?? [] + ).map((c: any) => ({ + credentialProviderType: c.credentialProviderType ?? '', + credentialProvider: c.credentialProvider + ? { + oauthCredentialProvider: c.credentialProvider.oauthCredentialProvider + ? { + providerArn: c.credentialProvider.oauthCredentialProvider.providerArn ?? '', + scopes: c.credentialProvider.oauthCredentialProvider.scopes, + } + : undefined, + apiKeyCredentialProvider: c.credentialProvider.apiKeyCredentialProvider + ? { providerArn: c.credentialProvider.apiKeyCredentialProvider.providerArn ?? '' } + : undefined, + } + : undefined, + })); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ + + return { + targetId: response.targetId ?? '', + name: response.name ?? '', + status: response.status ?? 'UNKNOWN', + description: response.description, + targetConfiguration, + credentialProviderConfigurations, + }; +} diff --git a/src/cli/aws/agentcore-http-gateways.ts b/src/cli/aws/agentcore-http-gateways.ts new file mode 100644 index 000000000..674f090a0 --- /dev/null +++ b/src/cli/aws/agentcore-http-gateways.ts @@ -0,0 +1,519 @@ +/** + * AWS client wrappers for HTTP Gateway control plane operations. + * + * HTTP gateways are required for A/B testing because MCP gateways + * don't emit spans for treatment propagation. These wrappers use + * direct HTTP requests with SigV4 signing against the control plane. + */ +import { getCredentialProvider } from './account'; +import { dnsSuffix } from './partition'; +import { Sha256 } from '@aws-crypto/sha256-js'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; +import { randomUUID } from 'node:crypto'; + +// ============================================================================ +// Types +// ============================================================================ + +// ── Create Gateway ───────────────────────────────────────────────────────── + +export interface CreateHttpGatewayOptions { + region: string; + name: string; + roleArn: string; +} + +export interface CreateHttpGatewayResult { + gatewayId: string; + gatewayArn: string; + name: string; + status: string; +} + +// ── Create Gateway Target ────────────────────────────────────────────────── + +export interface CreateHttpGatewayTargetOptions { + region: string; + gatewayId: string; + targetName: string; + runtimeArn: string; + qualifier?: string; +} + +export interface CreateHttpGatewayTargetResult { + targetId: string; + name: string; + status: string; +} + +// ── Get Gateway ──────────────────────────────────────────────────────────── + +export interface GetHttpGatewayOptions { + region: string; + gatewayId: string; +} + +export interface GetHttpGatewayResult { + gatewayId: string; + gatewayArn: string; + gatewayUrl?: string; + name: string; + status: string; + authorizerType?: string; + roleArn?: string; + createdAt?: string; + updatedAt?: string; +} + +// ── Get Gateway Target ───────────────────────────────────────────────────── + +export interface GetHttpGatewayTargetOptions { + region: string; + gatewayId: string; + targetId: string; +} + +export interface GetHttpGatewayTargetResult { + targetId: string; + name: string; + status: string; + targetConfiguration?: unknown; + createdAt?: string; + updatedAt?: string; +} + +// ── List Gateways ────────────────────────────────────────────────────────── + +export interface ListHttpGatewaysOptions { + region: string; + maxResults?: number; + nextToken?: string; +} + +export interface HttpGatewaySummary { + gatewayId: string; + gatewayArn: string; + name: string; + status: string; +} + +export interface ListHttpGatewaysResult { + gateways: HttpGatewaySummary[]; + nextToken?: string; +} + +// ── List Gateway Targets ────────────────────────────────────────────────── + +export interface ListHttpGatewayTargetsOptions { + region: string; + gatewayId: string; + maxResults?: number; +} + +export interface HttpGatewayTargetSummary { + targetId: string; + name: string; + status: string; +} + +export interface ListHttpGatewayTargetsResult { + targets: HttpGatewayTargetSummary[]; +} + +// ── Delete Gateway Target ────────────────────────────────────────────────── + +export interface DeleteHttpGatewayTargetOptions { + region: string; + gatewayId: string; + targetId: string; +} + +// ── Delete Gateway ───────────────────────────────────────────────────────── + +export interface DeleteHttpGatewayOptions { + region: string; + gatewayId: string; +} + +// ── Wait for Target Ready ────────────────────────────────────────────────── + +export interface WaitForTargetReadyOptions { + region: string; + gatewayId: string; + targetId: string; + /** Maximum time to wait in milliseconds. Defaults to 120000 (120s). */ + timeoutMs?: number; +} + +// ============================================================================ +// HTTP signing helper +// ============================================================================ + +function getControlPlaneEndpoint(region: string): string { + const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + if (stage === 'beta') return `https://beta.${region}.elcapcp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapcp.genesis-primitives.aws.dev`; + return `https://bedrock-agentcore-control.${region}.${dnsSuffix(region)}`; +} + +async function signedRequest(options: { + region: string; + method: string; + path: string; + body?: string; +}): Promise { + const { region, method, path, body } = options; + const endpoint = getControlPlaneEndpoint(region); + const url = new URL(path, endpoint); + + const query: Record = {}; + url.searchParams.forEach((value, key) => { + query[key] = value; + }); + + const request = new HttpRequest({ + method, + protocol: 'https:', + hostname: url.hostname, + path: url.pathname, + ...(Object.keys(query).length > 0 && { query }), + headers: { + 'Content-Type': 'application/json', + host: url.hostname, + }, + ...(body && { body }), + }); + + const credentials = getCredentialProvider() ?? defaultProvider(); + const service = 'bedrock-agentcore'; + const signer = new SignatureV4({ + service, + region, + credentials, + sha256: Sha256, + }); + + const signedReq = await signer.sign(request); + + const response = await fetch(`${endpoint}${path}`, { + method, + headers: signedReq.headers as Record, + ...(body && { body }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`HttpGateway API error (${response.status}): ${errorBody}`); + } + + if (response.status === 204) return {}; + return response.json(); +} + +// ============================================================================ +// Control Plane Operations +// ============================================================================ + +export async function createHttpGateway(options: CreateHttpGatewayOptions): Promise { + const body = JSON.stringify({ + name: options.name, + authorizerType: 'AWS_IAM', + roleArn: options.roleArn, + clientToken: randomUUID(), + }); + + try { + return (await signedRequest({ + region: options.region, + method: 'POST', + path: '/gateways', + body, + })) as CreateHttpGatewayResult; + } catch (err) { + throw new Error( + `Failed to create HTTP gateway "${options.name}": ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +export async function createHttpGatewayTarget( + options: CreateHttpGatewayTargetOptions +): Promise { + const body = JSON.stringify({ + name: options.targetName, + clientToken: randomUUID(), + targetConfiguration: { + http: { + agentcoreRuntime: { + arn: options.runtimeArn, + qualifier: options.qualifier ?? 'DEFAULT', + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: 'GATEWAY_IAM_ROLE' }], + }); + + try { + return (await signedRequest({ + region: options.region, + method: 'POST', + path: `/gateways/${options.gatewayId}/targets`, + body, + })) as CreateHttpGatewayTargetResult; + } catch (err) { + // Fallback: retry with legacy field name if the new name is not yet supported + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('ValidationException') || msg.includes('400')) { + const legacyBody = JSON.stringify({ + name: options.targetName, + clientToken: randomUUID(), + targetConfiguration: { + http: { + runtimeTargetConfiguration: { + arn: options.runtimeArn, + qualifier: options.qualifier ?? 'DEFAULT', + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: 'GATEWAY_IAM_ROLE' }], + }); + try { + return (await signedRequest({ + region: options.region, + method: 'POST', + path: `/gateways/${options.gatewayId}/targets`, + body: legacyBody, + })) as CreateHttpGatewayTargetResult; + } catch { + // Fall through to original error + } + } + throw new Error(`Failed to create target "${options.targetName}" in gateway ${options.gatewayId}: ${msg}`); + } +} + +export async function getHttpGateway(options: GetHttpGatewayOptions): Promise { + const data = await signedRequest({ + region: options.region, + method: 'GET', + path: `/gateways/${options.gatewayId}`, + }); + + return data as GetHttpGatewayResult; +} + +export async function getHttpGatewayTarget(options: GetHttpGatewayTargetOptions): Promise { + const data = await signedRequest({ + region: options.region, + method: 'GET', + path: `/gateways/${options.gatewayId}/targets/${options.targetId}`, + }); + + return data as GetHttpGatewayTargetResult; +} + +export async function listHttpGateways(options: ListHttpGatewaysOptions): Promise { + const params = new URLSearchParams(); + if (options.maxResults) params.set('maxResults', String(options.maxResults)); + if (options.nextToken) params.set('nextToken', options.nextToken); + const query = params.toString(); + + const data = await signedRequest({ + region: options.region, + method: 'GET', + path: `/gateways${query ? `?${query}` : ''}`, + }); + + const result = data as ListHttpGatewaysResult; + return { + gateways: result.gateways ?? [], + nextToken: result.nextToken, + }; +} + +/** + * List all HTTP gateways, paginating through all results. + */ +export async function listAllHttpGateways(options: { region: string }): Promise { + const all: HttpGatewaySummary[] = []; + let nextToken: string | undefined; + + do { + const result = await listHttpGateways({ region: options.region, maxResults: 100, nextToken }); + all.push(...result.gateways); + nextToken = result.nextToken; + } while (nextToken); + + return all; +} + +export async function listHttpGatewayTargets( + options: ListHttpGatewayTargetsOptions +): Promise { + const params = new URLSearchParams(); + if (options.maxResults) params.set('maxResults', String(options.maxResults)); + const query = params.toString(); + + const data = await signedRequest({ + region: options.region, + method: 'GET', + path: `/gateways/${options.gatewayId}/targets${query ? `?${query}` : ''}`, + }); + + const result = data as Record; + return { + targets: (result.items ?? result.targets ?? []) as HttpGatewayTargetSummary[], + }; +} + +export async function deleteHttpGatewayTarget( + options: DeleteHttpGatewayTargetOptions +): Promise<{ success: boolean; error?: string }> { + try { + await signedRequest({ + region: options.region, + method: 'DELETE', + path: `/gateways/${options.gatewayId}/targets/${options.targetId}`, + }); + + // Wait for target to be fully deleted before returning. + // Gateway deletion fails if targets still exist in DELETING state. + const timeoutMs = 60_000; + const startTime = Date.now(); + let delayMs = 2_000; + + while (Date.now() - startTime < timeoutMs) { + try { + await getHttpGatewayTarget({ + region: options.region, + gatewayId: options.gatewayId, + targetId: options.targetId, + }); + // Target still exists — keep waiting + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('(404)') || msg.includes('not found')) { + return { success: true }; // Target confirmed deleted + } + // Transient error — keep polling + } + + const remaining = timeoutMs - (Date.now() - startTime); + if (remaining <= 0) break; + await new Promise(resolve => setTimeout(resolve, Math.min(delayMs, remaining))); + delayMs = Math.min(delayMs * 2, 8_000); + } + + // Polling timed out — target may still be deleting + return { success: false, error: `Timed out waiting for target ${options.targetId} to be fully deleted` }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function deleteHttpGateway( + options: DeleteHttpGatewayOptions +): Promise<{ success: boolean; error?: string }> { + try { + await signedRequest({ + region: options.region, + method: 'DELETE', + path: `/gateways/${options.gatewayId}`, + }); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Terminal states that indicate a resource will never become READY. */ +const TERMINAL_FAILURE_STATES = ['FAILED', 'CREATE_FAILED', 'UPDATE_FAILED', 'DELETING', 'DELETED'] as const; + +export async function waitForGatewayReady(options: { + region: string; + gatewayId: string; + timeoutMs?: number; +}): Promise { + const timeoutMs = options.timeoutMs ?? 120_000; + const startTime = Date.now(); + let delayMs = 2_000; + + while (Date.now() - startTime < timeoutMs) { + const gateway = await getHttpGateway({ + region: options.region, + gatewayId: options.gatewayId, + }); + + if (gateway.status === 'READY') return gateway; + + if ((TERMINAL_FAILURE_STATES as readonly string[]).includes(gateway.status)) { + throw new Error( + `Gateway ${options.gatewayId} reached terminal state '${gateway.status}' and will not become READY` + ); + } + + const remaining = timeoutMs - (Date.now() - startTime); + if (remaining <= 0) break; + + await new Promise(resolve => setTimeout(resolve, Math.min(delayMs, remaining))); + delayMs = Math.min(delayMs * 2, 16_000); + } + + throw new Error( + `Timed out waiting for gateway ${options.gatewayId} to become READY after ${Math.round(timeoutMs / 1000)}s` + ); +} + +export async function waitForTargetReady(options: WaitForTargetReadyOptions): Promise { + const timeoutMs = options.timeoutMs ?? 120_000; + const startTime = Date.now(); + let delayMs = 2_000; + + while (Date.now() - startTime < timeoutMs) { + let target; + try { + target = await getHttpGatewayTarget({ + region: options.region, + gatewayId: options.gatewayId, + targetId: options.targetId, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('(404)')) { + throw new Error( + `Target ${options.targetId} not found during readiness poll — it may have been deleted externally` + ); + } + // Retry on transient server errors + if (/\(5\d\d\)/.test(msg)) { + // Continue polling — transient error + const remaining = timeoutMs - (Date.now() - startTime); + if (remaining <= 0) break; + await new Promise(resolve => setTimeout(resolve, delayMs)); + delayMs = Math.min(delayMs * 2, 16_000); + continue; + } + throw err; + } + + if (target.status === 'READY') return target; + + if ((TERMINAL_FAILURE_STATES as readonly string[]).includes(target.status)) { + throw new Error( + `Target ${options.targetId} in gateway ${options.gatewayId} reached terminal state '${target.status}' and will not become READY` + ); + } + + const remaining = timeoutMs - (Date.now() - startTime); + if (remaining <= 0) break; + + await new Promise(resolve => setTimeout(resolve, Math.min(delayMs, remaining))); + delayMs = Math.min(delayMs * 2, 16_000); + } + + throw new Error( + `Timed out waiting for target ${options.targetId} to become READY after ${Math.round(timeoutMs / 1000)}s` + ); +} diff --git a/src/cli/aws/agentcore-recommendation.ts b/src/cli/aws/agentcore-recommendation.ts new file mode 100644 index 000000000..55242fdcd --- /dev/null +++ b/src/cli/aws/agentcore-recommendation.ts @@ -0,0 +1,371 @@ +/** + * AWS client wrappers for Recommendation API operations. + * + * NOTE: The Recommendation API is not yet available in the AWS SDK. + * These wrappers use direct HTTP requests with SigV4 signing as an + * interim solution. When the SDK adds Recommendation commands, migrate + * to the SDK client. + * + * TEMPORARY: All Recommendation endpoints are on the Data Plane (DP), + * not the Control Plane. This is the current API shape as of 2026-03-30. + * The API may move to CP in the future — update endpoints accordingly. + * + * Recommendations are one-shot, immutable resources. There is no Update + * operation and no runs sub-resource. You start a recommendation with + * StartRecommendation, poll via GetRecommendation, and stop via + * DeleteRecommendation (stop-via-delete pattern). + */ +import { getCredentialProvider } from './account'; +import { dnsSuffix } from './partition'; +import { Sha256 } from '@aws-crypto/sha256-js'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; + +// ============================================================================ +// Types — Recommendation Type Enum +// ============================================================================ + +export type RecommendationType = 'SYSTEM_PROMPT_RECOMMENDATION' | 'TOOL_DESCRIPTION_RECOMMENDATION'; + +// ============================================================================ +// Types — Input Config (tag-union per type) +// ============================================================================ + +/** System prompt source — either inline text or a ConfigBundle reference. */ +export interface SystemPromptSource { + text?: string; + configurationBundle?: { + bundleArn: string; + versionId?: string; + systemPromptJsonPath?: string; + }; +} + +/** A single OTEL-style span for inline session traces. */ +export interface SessionSpan { + scope?: { name: string }; + body?: { + input?: { messages?: { content: unknown; role: string }[] }; + output?: { messages?: { content: unknown; role: string }[] }; + }; + attributes?: Record; + traceId: string; + spanId: string; +} + +/** Agent trace source — inline spans or CloudWatch Logs. */ +export interface AgentTracesSource { + sessionSpans?: SessionSpan[]; + cloudwatchLogs?: { + logGroupArns: string[]; + serviceNames: string[]; + startTime: string; + endTime: string; + limit?: number; + sessionIds?: string[]; + }; +} + +/** Evaluation config — exactly one evaluator as objective signal (API constraint: min 1, max 1). */ +export interface RecommendationEvaluationConfig { + evaluators: [{ evaluatorArn: string }]; +} + +/** Config for SYSTEM_PROMPT_RECOMMENDATION type. */ +export interface SystemPromptRecommendationConfig { + systemPrompt: SystemPromptSource; + agentTraces: AgentTracesSource; + evaluationConfig: RecommendationEvaluationConfig; +} + +/** Config for TOOL_DESCRIPTION_RECOMMENDATION type. */ +export interface ToolDescriptionRecommendationConfig { + toolDescription: { + toolDescriptionText?: { + tools: { toolName: string; toolDescription: { text: string } }[]; + }; + configurationBundle?: { + bundleArn: string; + versionId?: string; + tools: { toolName: string; toolDescriptionJsonPath: string }[]; + }; + }; + agentTraces: AgentTracesSource; +} + +/** Tag-union recommendation config — only populate the member matching the type. */ +export interface RecommendationConfig { + systemPromptRecommendationConfig?: SystemPromptRecommendationConfig; + toolDescriptionRecommendationConfig?: ToolDescriptionRecommendationConfig; +} + +// ============================================================================ +// Types — Result (tag-union per type) +// ============================================================================ + +export interface RecommendationResultConfigurationBundle { + bundleArn: string; + versionId: string; +} + +export interface SystemPromptRecommendationResult { + recommendedSystemPrompt?: string; + configurationBundle?: RecommendationResultConfigurationBundle; + errorCode?: string; + errorMessage?: string; +} + +export interface ToolDescriptionRecommendationToolResult { + toolName: string; + recommendedToolDescription: string; +} + +export interface ToolDescriptionRecommendationResult { + tools?: ToolDescriptionRecommendationToolResult[]; + configurationBundle?: RecommendationResultConfigurationBundle; + errorCode?: string; + errorMessage?: string; +} + +export interface RecommendationResult { + systemPromptRecommendationResult?: SystemPromptRecommendationResult; + toolDescriptionRecommendationResult?: ToolDescriptionRecommendationResult; +} + +// ============================================================================ +// Types — API Options & Results +// ============================================================================ + +export interface StartRecommendationOptions { + region: string; + name: string; + description?: string; + type: RecommendationType; + recommendationConfig: RecommendationConfig; + kmsKeyArn?: string; + clientToken?: string; +} + +export interface StartRecommendationResult { + recommendationId: string; + recommendationArn: string; + name: string; + type: string; + status: string; + createdAt?: string; + updatedAt?: string; + requestId?: string; +} + +export interface GetRecommendationOptions { + region: string; + recommendationId: string; +} + +export interface GetRecommendationResult { + recommendationId: string; + recommendationArn: string; + name: string; + description?: string; + type: string; + recommendationConfig?: RecommendationConfig; + status: string; + statusReasons?: string[]; + createdAt?: string; + updatedAt?: string; + completedAt?: string; + recommendationResult?: RecommendationResult; + requestId?: string; +} + +export interface ListRecommendationsOptions { + region: string; + status?: string; + maxResults?: number; + nextToken?: string; +} + +export interface RecommendationSummary { + recommendationId: string; + recommendationArn: string; + name: string; + description?: string; + type: string; + status: string; + createdAt?: string; + updatedAt?: string; +} + +export interface ListRecommendationsResult { + recommendationSummaries: RecommendationSummary[]; + nextToken?: string; +} + +export interface DeleteRecommendationOptions { + region: string; + recommendationId: string; +} + +export interface DeleteRecommendationResult { + recommendationId: string; + status: string; +} + +// ============================================================================ +// HTTP signing helper +// ============================================================================ + +/** + * Resolve the DP endpoint for the Recommendation API. + * + * TEMPORARY: All Recommendation endpoints are on the Data Plane. + * Set AGENTCORE_STAGE=beta|gamma to target pre-release environments. + */ +function getDataPlaneEndpoint(region: string): string { + const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + if (stage === 'beta') return `https://beta.${region}.elcapdp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapdp.genesis-primitives.aws.dev`; + return `https://bedrock-agentcore.${region}.${dnsSuffix(region)}`; +} + +async function signedRequest(options: { + region: string; + method: string; + path: string; + body?: string; +}): Promise<{ data: unknown; status: number; requestId?: string }> { + const { region, method, path, body } = options; + const endpoint = getDataPlaneEndpoint(region); + const url = new URL(path, endpoint); + + const query: Record = {}; + url.searchParams.forEach((value, key) => { + query[key] = value; + }); + + const request = new HttpRequest({ + method, + protocol: 'https:', + hostname: url.hostname, + path: url.pathname, + ...(Object.keys(query).length > 0 && { query }), + headers: { + 'Content-Type': 'application/json', + host: url.hostname, + }, + ...(body && { body }), + }); + + const credentials = getCredentialProvider() ?? defaultProvider(); + const signer = new SignatureV4({ + service: 'bedrock-agentcore', + region, + credentials, + sha256: Sha256, + }); + + const signedReq = await signer.sign(request); + + const response = await fetch(`${endpoint}${path}`, { + method, + headers: signedReq.headers as Record, + ...(body && { body }), + }); + + const requestId = response.headers.get('x-amzn-requestid') ?? 'unknown'; + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`Recommendation API error (${response.status}): ${errorBody} [requestId: ${requestId}]`); + } + + if (response.status === 204) return { data: {}, status: 204, requestId }; + return { data: await response.json(), status: response.status, requestId }; +} + +// ============================================================================ +// API Operations +// ============================================================================ + +/** + * Start a new recommendation (async — returns 202). + * Creates an ARN-able resource that progresses through: + * PENDING → IN_PROGRESS → COMPLETED | FAILED + */ +export async function startRecommendation(options: StartRecommendationOptions): Promise { + const body = JSON.stringify({ + name: options.name, + ...(options.description && { description: options.description }), + type: options.type, + recommendationConfig: options.recommendationConfig, + ...(options.kmsKeyArn && { kmsKeyArn: options.kmsKeyArn }), + ...(options.clientToken && { clientToken: options.clientToken }), + }); + + const { data, requestId } = await signedRequest({ + region: options.region, + method: 'POST', + path: '/recommendations', + body, + }); + + const result = data as StartRecommendationResult; + if (requestId) result.requestId = requestId; + return result; +} + +/** + * Get recommendation status and results. + * When status is COMPLETED, recommendationResult contains the optimized artifact. + */ +export async function getRecommendation(options: GetRecommendationOptions): Promise { + const { data, requestId } = await signedRequest({ + region: options.region, + method: 'GET', + path: `/recommendations/${options.recommendationId}`, + }); + + const result = data as GetRecommendationResult; + if (requestId) result.requestId = requestId; + return result; +} + +/** + * List recommendations with optional filtering and pagination. + */ +export async function listRecommendations(options: ListRecommendationsOptions): Promise { + const params = new URLSearchParams(); + if (options.status) params.set('status', options.status); + if (options.maxResults) params.set('maxResults', String(options.maxResults)); + if (options.nextToken) params.set('nextToken', options.nextToken); + + const query = params.toString(); + const path = `/recommendations${query ? `?${query}` : ''}`; + + const { data } = await signedRequest({ + region: options.region, + method: 'GET', + path, + }); + + const result = data as ListRecommendationsResult; + return { + recommendationSummaries: result.recommendationSummaries ?? [], + nextToken: result.nextToken, + }; +} + +/** + * Delete a recommendation. Also stops in-progress recommendations + * (stop-via-delete pattern — no separate Stop API). + */ +export async function deleteRecommendation(options: DeleteRecommendationOptions): Promise { + const { data } = await signedRequest({ + region: options.region, + method: 'DELETE', + path: `/recommendations/${options.recommendationId}`, + }); + + return data as DeleteRecommendationResult; +} diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index 55c19d2d0..25c5f4c9a 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -5,7 +5,6 @@ import { serviceEndpoint } from './partition'; import { BedrockAgentCoreClient, EvaluateCommand, - type EvaluationReferenceInput, InvokeAgentRuntimeCommand, InvokeAgentRuntimeCommandCommand, StopRuntimeSessionCommand, @@ -13,6 +12,14 @@ import { import type { HttpRequest } from '@smithy/protocol-http'; import type { DocumentType } from '@smithy/types'; +/** Local definition — SDK does not yet export this type. */ +export interface EvaluationReferenceInput { + context: { spanContext: { sessionId: string; traceId?: string } }; + expectedTrajectory?: { toolNames: string[] }; + assertions?: { text: string }[]; + expectedResponse?: { text: string }; +} + /** * Create a BedrockAgentCoreClient with optional custom header injection middleware. */ @@ -59,6 +66,8 @@ export interface InvokeAgentRuntimeOptions { headers?: Record; /** Bearer token for CUSTOM_JWT auth. When provided, uses raw HTTP with Authorization header instead of SigV4. */ bearerToken?: string; + /** W3C baggage header value (e.g. config bundle ref for runtime) */ + baggage?: string; } export interface InvokeAgentRuntimeResult { @@ -151,20 +160,40 @@ function buildInvokeUrl(region: string, runtimeArn: string): string { } /** - * Invoke an AgentCore Runtime using bearer token auth (raw HTTP, no SigV4). - * Used when the runtime has CUSTOM_JWT authorizer configured. + * Build headers for bearer-token invoke requests. + * Shared by both streaming and non-streaming invoke paths. */ -async function invokeWithBearerTokenStreaming(options: InvokeAgentRuntimeOptions): Promise { - const url = buildInvokeUrl(options.region, options.runtimeArn); +export function buildBearerInvokeHeaders( + options: Pick, + accept: string +): Record { const headers: Record = { Authorization: `Bearer ${options.bearerToken}`, 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', + Accept: accept, }; if (options.sessionId) { headers['X-Amzn-Bedrock-AgentCore-Runtime-Session-Id'] = options.sessionId; } headers['X-Amzn-Bedrock-AgentCore-Runtime-User-Id'] = options.userId ?? DEFAULT_RUNTIME_USER_ID; + if (options.baggage) { + headers.baggage = options.baggage; + } + if (options.headers) { + for (const [name, value] of Object.entries(options.headers)) { + headers[name] = value; + } + } + return headers; +} + +/** + * Invoke an AgentCore Runtime using bearer token auth (raw HTTP, no SigV4). + * Used when the runtime has CUSTOM_JWT authorizer configured. + */ +async function invokeWithBearerTokenStreaming(options: InvokeAgentRuntimeOptions): Promise { + const url = buildInvokeUrl(options.region, options.runtimeArn); + const headers = buildBearerInvokeHeaders(options, 'application/json, text/event-stream'); const res = await fetch(url, { method: 'POST', @@ -250,15 +279,7 @@ async function invokeWithBearerTokenStreaming(options: InvokeAgentRuntimeOptions */ async function invokeWithBearerToken(options: InvokeAgentRuntimeOptions): Promise { const url = buildInvokeUrl(options.region, options.runtimeArn); - const headers: Record = { - Authorization: `Bearer ${options.bearerToken}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }; - if (options.sessionId) { - headers['X-Amzn-Bedrock-AgentCore-Runtime-Session-Id'] = options.sessionId; - } - headers['X-Amzn-Bedrock-AgentCore-Runtime-User-Id'] = options.userId ?? DEFAULT_RUNTIME_USER_ID; + const headers = buildBearerInvokeHeaders(options, 'application/json'); const res = await fetch(url, { method: 'POST', @@ -300,6 +321,7 @@ export async function invokeAgentRuntimeStreaming(options: InvokeAgentRuntimeOpt accept: 'application/json', runtimeSessionId: options.sessionId, runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID, + ...(options.baggage && { baggage: options.baggage }), }); const response = await client.send(command); @@ -395,6 +417,7 @@ export async function invokeAgentRuntime(options: InvokeAgentRuntimeOptions): Pr accept: 'application/json', runtimeSessionId: options.sessionId, runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID, + ...(options.baggage && { baggage: options.baggage }), }); const response = await client.send(command); diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts index 5ce91528a..c44ff37f4 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -48,6 +48,24 @@ export { type StopRuntimeSessionOptions, type StopRuntimeSessionResult, } from './agentcore'; +export { + startRecommendation, + getRecommendation, + listRecommendations, + deleteRecommendation, + type StartRecommendationOptions, + type StartRecommendationResult, + type GetRecommendationOptions, + type GetRecommendationResult, + type ListRecommendationsOptions, + type ListRecommendationsResult, + type DeleteRecommendationOptions, + type DeleteRecommendationResult, + type RecommendationSummary, + type RecommendationType, + type RecommendationConfig, + type RecommendationResult, +} from './agentcore-recommendation'; export { AguiEventType, AguiErrorCode, diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 72cf5c50d..e40732f52 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,4 +1,8 @@ +import { getOrCreateInstallationId } from '../lib/schemas/io/global-config'; +import { registerABTestCommand } from './commands/abtest'; import { registerAdd } from './commands/add'; +import { registerArchive } from './commands/archive'; +import { registerConfigBundle } from './commands/config-bundle'; import { registerCreate } from './commands/create'; import { registerDeploy } from './commands/deploy'; import { registerDev } from './commands/dev'; @@ -9,18 +13,20 @@ import { registerImport } from './commands/import'; import { registerInvoke } from './commands/invoke'; import { registerLogs } from './commands/logs'; import { registerPackage } from './commands/package'; -import { registerPause } from './commands/pause'; +import { registerPause, registerPromote } from './commands/pause'; +import { registerRecommendations } from './commands/recommendations'; import { registerRemove } from './commands/remove'; import { registerResume } from './commands/resume'; import { registerRun } from './commands/run'; import { registerStatus } from './commands/status'; +import { registerStop } from './commands/stop'; import { registerTelemetry } from './commands/telemetry'; import { registerTraces } from './commands/traces'; import { registerUpdate } from './commands/update'; import { registerValidate } from './commands/validate'; import { PACKAGE_VERSION } from './constants'; -import { getOrCreateInstallationId } from './global-config'; import { ALL_PRIMITIVES } from './primitives'; +import { TelemetryClientAccessor } from './telemetry'; import { App } from './tui/App'; import { LayoutProvider } from './tui/context'; import { COMMAND_DESCRIPTIONS } from './tui/copy'; @@ -181,19 +187,27 @@ export function registerCommands(program: Command) { registerLogs(program); registerPackage(program); registerPause(program); + registerRecommendations(program); const removeCmd = registerRemove(program); registerResume(program); registerRun(program); registerStatus(program); + registerStop(program); + registerPromote(program); registerTelemetry(program); registerTraces(program); registerUpdate(program); registerValidate(program); + registerConfigBundle(program); + registerArchive(program); // Register primitive subcommands (add agent, remove agent, add memory, etc.) for (const primitive of ALL_PRIMITIVES) { primitive.registerCommands(addCmd, removeCmd); } + + // Register AB test detail command + registerABTestCommand(program); } export const main = async (argv: string[]) => { @@ -207,7 +221,7 @@ export const main = async (argv: string[]) => { const args = argv.slice(2); - // Fire off non-blocking update check (skip for `update` command) + // Fire off non-blocking update check (skip for `update` command itself) const isUpdateCommand = args[0] === 'update'; const updateCheck = isUpdateCommand ? Promise.resolve(null) : checkForUpdate(); @@ -222,7 +236,12 @@ export const main = async (argv: string[]) => { printTelemetryNotice(); } - await program.parseAsync(argv); + TelemetryClientAccessor.init(args[0] ?? 'unknown'); + try { + await program.parseAsync(argv); + } finally { + await TelemetryClientAccessor.shutdown(); + } // Telemetry notice already printed above; only run update check here. await printPostCommandNotices(false, updateCheck); diff --git a/src/cli/cloudformation/__tests__/outputs-extended.test.ts b/src/cli/cloudformation/__tests__/outputs-extended.test.ts index cbe82085e..1f48faa96 100644 --- a/src/cli/cloudformation/__tests__/outputs-extended.test.ts +++ b/src/cli/cloudformation/__tests__/outputs-extended.test.ts @@ -364,7 +364,7 @@ describe('parseOnlineEvalOutputs', () => { 'arn:aws:bedrock:us-east-1:123:online-evaluation-config/proj_TestConfig-xyz', }; - const result = parseOnlineEvalOutputs(outputs, ['TestConfig']); + const result = parseOnlineEvalOutputs(outputs, [{ name: 'TestConfig' }]); expect(result.TestConfig).toBeDefined(); expect(result.TestConfig!.onlineEvaluationConfigId).toBe('proj_TestConfig-xyz'); expect(result.TestConfig!.onlineEvaluationConfigArn).toBe( @@ -380,7 +380,7 @@ describe('parseOnlineEvalOutputs', () => { ApplicationOnlineEvalConfigBArnOutputD: 'arn:b', }; - const result = parseOnlineEvalOutputs(outputs, ['ConfigA', 'ConfigB']); + const result = parseOnlineEvalOutputs(outputs, [{ name: 'ConfigA' }, { name: 'ConfigB' }]); expect(Object.keys(result)).toHaveLength(2); expect(result.ConfigA!.onlineEvaluationConfigId).toBe('id-a'); expect(result.ConfigB!.onlineEvaluationConfigId).toBe('id-b'); @@ -391,12 +391,12 @@ describe('parseOnlineEvalOutputs', () => { ApplicationOnlineEvalTestConfigArnOutputDEF: 'arn:config', }; - const result = parseOnlineEvalOutputs(outputs, ['TestConfig']); + const result = parseOnlineEvalOutputs(outputs, [{ name: 'TestConfig' }]); expect(result.TestConfig).toBeUndefined(); }); it('returns empty record for empty outputs', () => { - const result = parseOnlineEvalOutputs({}, ['TestConfig']); + const result = parseOnlineEvalOutputs({}, [{ name: 'TestConfig' }]); expect(result).toEqual({}); }); }); diff --git a/src/cli/cloudformation/__tests__/outputs.test.ts b/src/cli/cloudformation/__tests__/outputs.test.ts index d12ddb689..24f39b451 100644 --- a/src/cli/cloudformation/__tests__/outputs.test.ts +++ b/src/cli/cloudformation/__tests__/outputs.test.ts @@ -469,3 +469,117 @@ describe('buildDeployedState with policy data', () => { expect(result.targets.default!.resources?.policyEngines).toBeUndefined(); }); }); + +describe('buildDeployedState carry-forward', () => { + it('carries forward abTests from existing state', () => { + const existingState = { + targets: { + default: { + resources: { + stackName: 'TestStack', + abTests: { + TestExperiment: { + abTestId: 'abt-123', + abTestArn: 'arn:aws:bedrock:us-east-1:123456789012:ab-test/abt-123', + }, + }, + }, + }, + }, + }; + + const result = buildDeployedState({ + targetName: 'default', + stackName: 'TestStack', + agents: {}, + gateways: {}, + existingState, + }); + + expect(result.targets.default!.resources?.abTests).toEqual({ + TestExperiment: { + abTestId: 'abt-123', + abTestArn: 'arn:aws:bedrock:us-east-1:123456789012:ab-test/abt-123', + }, + }); + }); + + it('carries forward httpGateways from existing state', () => { + const existingState = { + targets: { + default: { + resources: { + stackName: 'TestStack', + httpGateways: { + MyHttpGw: { + gatewayId: 'hgw-456', + gatewayArn: 'arn:aws:bedrock:us-east-1:123456789012:http-gateway/hgw-456', + }, + }, + }, + }, + }, + }; + + const result = buildDeployedState({ + targetName: 'default', + stackName: 'TestStack', + agents: {}, + gateways: {}, + existingState, + }); + + expect(result.targets.default!.resources?.httpGateways).toEqual({ + MyHttpGw: { + gatewayId: 'hgw-456', + gatewayArn: 'arn:aws:bedrock:us-east-1:123456789012:http-gateway/hgw-456', + }, + }); + }); + + it('does not carry forward empty abTests', () => { + const existingState = { + targets: { + default: { + resources: { + stackName: 'TestStack', + abTests: {}, + }, + }, + }, + }; + + const result = buildDeployedState({ + targetName: 'default', + stackName: 'TestStack', + agents: {}, + gateways: {}, + existingState, + }); + + expect(result.targets.default!.resources?.abTests).toBeUndefined(); + }); + + it('does not carry forward empty httpGateways', () => { + const existingState = { + targets: { + default: { + resources: { + stackName: 'TestStack', + httpGateways: {}, + }, + }, + }, + }; + + const result = buildDeployedState({ + targetName: 'default', + stackName: 'TestStack', + agents: {}, + gateways: {}, + existingState, + }); + + expect(result.targets.default!.resources?.httpGateways).toBeUndefined(); + }); +}); diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 28ced03c1..377cc3e9d 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -250,13 +250,13 @@ export function parseEvaluatorOutputs( */ export function parseOnlineEvalOutputs( outputs: StackOutputs, - onlineEvalNames: string[] + onlineEvalSpecs: { name: string; agent?: string; endpoint?: string }[] ): Record { const configs: Record = {}; const outputKeys = Object.keys(outputs); - for (const configName of onlineEvalNames) { - const pascal = toPascalId('OnlineEval', configName); + for (const spec of onlineEvalSpecs) { + const pascal = toPascalId('OnlineEval', spec.name); const idPrefix = `Application${pascal}IdOutput`; const arnPrefix = `Application${pascal}ArnOutput`; @@ -264,9 +264,11 @@ export function parseOnlineEvalOutputs( const arnKey = outputKeys.find(k => k.startsWith(arnPrefix)); if (idKey && arnKey) { - configs[configName] = { + configs[spec.name] = { onlineEvaluationConfigId: outputs[idKey]!, onlineEvaluationConfigArn: outputs[arnKey]!, + ...(spec.agent && { agent: spec.agent }), + ...(spec.endpoint && { endpoint: spec.endpoint }), }; } } @@ -446,6 +448,24 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta targetState.resources!.runtimeEndpoints = runtimeEndpoints; } + // Carry forward config bundles from existing state (managed post-deploy, not via CFN outputs) + const existingConfigBundles = existingState?.targets?.[targetName]?.resources?.configBundles; + if (existingConfigBundles && Object.keys(existingConfigBundles).length > 0) { + targetState.resources!.configBundles = existingConfigBundles; + } + + // Carry forward AB tests from existing state (managed post-deploy, not via CFN outputs) + const existingABTests = existingState?.targets?.[targetName]?.resources?.abTests; + if (existingABTests && Object.keys(existingABTests).length > 0) { + targetState.resources!.abTests = existingABTests; + } + + // Carry forward HTTP gateways from existing state (managed post-deploy, not via CFN outputs) + const existingHttpGateways = existingState?.targets?.[targetName]?.resources?.httpGateways; + if (existingHttpGateways && Object.keys(existingHttpGateways).length > 0) { + targetState.resources!.httpGateways = existingHttpGateways; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/abtest/command.ts b/src/cli/commands/abtest/command.ts new file mode 100644 index 000000000..cc236cdb3 --- /dev/null +++ b/src/cli/commands/abtest/command.ts @@ -0,0 +1,199 @@ +/** + * AB Test commands. + * + * `agentcore ab-test ` — fetches and displays full AB test details + * from the data plane API, including evaluation scores/metrics. + */ +import { ConfigIO } from '../../../lib'; +import { getABTest, listABTests } from '../../aws/agentcore-ab-tests'; +import type { GetABTestResult } from '../../aws/agentcore-ab-tests'; +import { dnsSuffix } from '../../aws/partition'; +import { getErrorMessage } from '../../errors'; +import type { Command } from '@commander-js/extra-typings'; + +// ============================================================================ +// Helpers +// ============================================================================ + +async function getRegion(cliRegion?: string): Promise { + if (cliRegion) return cliRegion; + try { + const configIO = new ConfigIO(); + const targets = await configIO.resolveAWSDeploymentTargets(); + if (targets.length > 0) return targets[0]!.region; + } catch { + // Fall through to env vars + } + return process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; +} + +async function resolveABTestId( + testName: string, + region: string +): Promise<{ abTestId: string; region: string; error?: string }> { + let projectName: string | undefined; + try { + const configIO = new ConfigIO(); + const deployedState = await configIO.readDeployedState(); + const awsTargets = await configIO.readAWSDeploymentTargets(); + + try { + const projectSpec = await configIO.readProjectSpec(); + projectName = projectSpec.name; + } catch { + // Project spec unavailable + } + + for (const [targetName, target] of Object.entries(deployedState.targets ?? {})) { + const abTests = target.resources?.abTests; + if (abTests?.[testName]) { + const targetConfig = awsTargets.find(t => t.name === targetName); + const resolvedRegion = targetConfig?.region ?? region; + return { abTestId: abTests[testName].abTestId, region: resolvedRegion }; + } + } + } catch { + // No deployed state available + } + + try { + const result = await listABTests({ region, maxResults: 100 }); + // Match against both prefixed name ({projectName}_{testName}) and bare testName (backwards compat) + const prefixedName = projectName ? `${projectName}_${testName}` : undefined; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- boolean OR, not nullish coalescing + const match = result.abTests.find(t => (prefixedName && t.name === prefixedName) || t.name === testName); + if (match) { + return { abTestId: match.abTestId, region }; + } + } catch { + // API call failed + } + + return { abTestId: '', region, error: `AB test "${testName}" not found in deployed state or API.` }; +} + +function gatewayUrlFromArn(arn: string): string { + const parts = arn.split(':'); + const region = parts[3]; + const gatewayId = parts[5]?.split('/')[1]; + if (region && gatewayId) { + return `https://${gatewayId}.gateway.bedrock-agentcore.${region}.${dnsSuffix(region)}`; + } + return arn; +} + +function formatABTestDetails(test: GetABTestResult): string { + const lines: string[] = []; + lines.push(`AB Test: ${test.name}`); + lines.push(` Status: ${test.status}`); + lines.push(` Execution: ${test.executionStatus}`); + lines.push(` Invocation URL: ${gatewayUrlFromArn(test.gatewayArn)}//invocations`); + lines.push( + ` Online Eval: ${'onlineEvaluationConfigArn' in test.evaluationConfig ? test.evaluationConfig.onlineEvaluationConfigArn : 'per-variant'}` + ); + if (test.description) lines.push(` Description: ${test.description}`); + + for (const variant of test.variants) { + const bundleRef = variant.variantConfiguration.configurationBundle; + const targetRef = variant.variantConfiguration.target; + if (targetRef) { + lines.push(` Variant ${variant.name}: weight=${variant.weight}, target=${targetRef.name}`); + } else if (bundleRef) { + lines.push( + ` Variant ${variant.name}: weight=${variant.weight}, bundle=${bundleRef.bundleArn}, version=${bundleRef.bundleVersion}` + ); + } + } + + // TODO(post-preview): Re-enable max duration display once configurable duration is launched. + // if (test.maxDurationDays) lines.push(` Max Duration: ${test.maxDurationDays} days`); + if (test.startedAt) lines.push(` Started: ${test.startedAt}`); + if (test.stoppedAt) lines.push(` Stopped: ${test.stoppedAt}`); + if (test.failureReason) lines.push(` Failure: ${test.failureReason}`); + + if (test.results) { + lines.push(' Results:'); + if (test.results.analysisTimestamp) { + lines.push(` Analysis Time: ${test.results.analysisTimestamp}`); + } + for (const metric of test.results.evaluatorMetrics) { + lines.push(` Evaluator: ${metric.evaluatorArn}`); + lines.push( + ` Control: samples=${metric.controlStats.sampleSize}, mean=${metric.controlStats.mean.toFixed(4)}` + ); + for (const vr of metric.variantResults) { + lines.push( + ` ${vr.treatmentName}: samples=${vr.sampleSize}, mean=${vr.mean.toFixed(4)}, significant=${vr.isSignificant}` + ); + if (vr.absoluteChange !== undefined) + lines.push(` Change: ${vr.absoluteChange.toFixed(4)} (${(vr.percentChange ?? 0).toFixed(2)}%)`); + if (vr.pValue !== undefined) lines.push(` p-value: ${vr.pValue.toFixed(6)}`); + if (vr.confidenceInterval) { + lines.push( + ` CI: [${vr.confidenceInterval.lower?.toFixed(4)}, ${vr.confidenceInterval.upper?.toFixed(4)}]` + ); + } + } + } + } + + return lines.join('\n'); +} + +// ============================================================================ +// Command registration +// ============================================================================ + +export function registerABTestCommand(program: Command): void { + program + .command('ab-test') + .description('[preview] View A/B test details and results') + .argument('', 'AB test name') + .option('--region ', 'AWS region') + .option('--json', 'Output as JSON') + .action(async (name: string, cliOptions: { region?: string; json?: boolean }) => { + try { + const region = await getRegion(cliOptions.region); + const { abTestId, error } = await resolveABTestId(name, region); + if (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + const result = await getABTest({ region, abTestId }); + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + process.exit(0); + } else if (process.stdout.isTTY) { + // Render TUI detail screen with key bindings + const [{ render }, { default: React }, { ABTestDetailScreen }] = await Promise.all([ + import('ink'), + import('react'), + import('../../tui/screens/ab-test'), + ]); + render( + React.createElement(ABTestDetailScreen, { + abTestId, + region, + onExit: () => process.exit(0), + }) + ); + return; + } else { + console.log(formatABTestDetails(result)); + process.exit(0); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + }); +} diff --git a/src/cli/commands/abtest/index.ts b/src/cli/commands/abtest/index.ts new file mode 100644 index 000000000..0ff25efc5 --- /dev/null +++ b/src/cli/commands/abtest/index.ts @@ -0,0 +1 @@ +export { registerABTestCommand } from './command'; diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index ad3b531b4..a066b1cc3 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -37,6 +37,7 @@ export interface AddAgentOptions extends VpcOptions { idleTimeout?: number | string; maxLifetime?: number | string; sessionStorageMountPath?: string; + withConfigBundle?: boolean; json?: boolean; } diff --git a/src/cli/commands/archive/__tests__/command.test.ts b/src/cli/commands/archive/__tests__/command.test.ts new file mode 100644 index 000000000..8d65c7099 --- /dev/null +++ b/src/cli/commands/archive/__tests__/command.test.ts @@ -0,0 +1,396 @@ +import { registerArchive } from '../command.js'; +import { Command } from '@commander-js/extra-typings'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockDeleteBatchEvaluation = vi.fn(); +const mockDeleteRecommendation = vi.fn(); +const mockDeleteLocalBatchEvalRun = vi.fn(); +const mockDeleteLocalRecommendationRun = vi.fn(); +const mockRequireProject = vi.fn(); +const mockRender = vi.fn(); +const mockResolveAWSDeploymentTargets = vi.fn(); + +vi.mock('../../../aws/agentcore-batch-evaluation', () => ({ + deleteBatchEvaluation: (...args: unknown[]) => mockDeleteBatchEvaluation(...args), +})); + +vi.mock('../../../aws/agentcore-recommendation', () => ({ + deleteRecommendation: (...args: unknown[]) => mockDeleteRecommendation(...args), +})); + +vi.mock('../../../operations/archive/archive-storage', () => ({ + deleteLocalBatchEvalRun: (...args: unknown[]) => mockDeleteLocalBatchEvalRun(...args), + deleteLocalRecommendationRun: (...args: unknown[]) => mockDeleteLocalRecommendationRun(...args), +})); + +vi.mock('../../../tui/guards', () => ({ + requireProject: (...args: unknown[]) => mockRequireProject(...args), +})); + +vi.mock('ink', () => ({ + render: (...args: unknown[]) => mockRender(...args), + Text: 'Text', +})); + +vi.mock('../../../../lib', () => ({ + ConfigIO: function () { + return { resolveAWSDeploymentTargets: () => mockResolveAWSDeploymentTargets() }; + }, +})); + +const batchEvalResult = { + batchEvaluationId: 'eval-abc-123', + batchEvaluationArn: 'arn:aws:bedrock:us-east-1:123456789:batch-evaluation/eval-abc-123', + status: 'DELETED', +}; + +const recommendationResult = { + recommendationId: 'rec-xyz-789', + status: 'DELETED', +}; + +describe('registerArchive', () => { + let program: Command; + let mockExit: ReturnType; + let mockLog: ReturnType; + + beforeEach(() => { + program = new Command(); + program.exitOverride(); + registerArchive(program); + + mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + mockResolveAWSDeploymentTargets.mockResolvedValue([{ region: 'us-east-1' }]); + mockDeleteLocalBatchEvalRun.mockReturnValue(true); + mockDeleteLocalRecommendationRun.mockReturnValue(true); + }); + + afterEach(() => { + mockExit.mockRestore(); + mockLog.mockRestore(); + vi.clearAllMocks(); + }); + + describe('command registration', () => { + it('registers archive command', () => { + const archiveCmd = program.commands.find(c => c.name() === 'archive'); + expect(archiveCmd).toBeDefined(); + }); + + it('registers batch-evaluation subcommand', () => { + const archiveCmd = program.commands.find(c => c.name() === 'archive')!; + const batchCmd = archiveCmd.commands.find(c => c.name() === 'batch-evaluation'); + expect(batchCmd).toBeDefined(); + }); + + it('registers recommendation subcommand', () => { + const archiveCmd = program.commands.find(c => c.name() === 'archive')!; + const recCmd = archiveCmd.commands.find(c => c.name() === 'recommendation'); + expect(recCmd).toBeDefined(); + }); + }); + + describe('archive batch-evaluation', () => { + it('rejects when --id is missing', async () => { + await expect(program.parseAsync(['archive', 'batch-evaluation'], { from: 'user' })).rejects.toThrow(); + expect(mockDeleteBatchEvaluation).not.toHaveBeenCalled(); + }); + + it('calls deleteBatchEvaluation with the given id and auto-detected region', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + expect(mockDeleteBatchEvaluation).toHaveBeenCalledWith({ + region: 'us-east-1', + batchEvaluationId: 'eval-abc-123', + }); + }); + + it('uses --region when provided', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--region', 'eu-west-1'], { + from: 'user', + }); + + expect(mockDeleteBatchEvaluation).toHaveBeenCalledWith({ + region: 'eu-west-1', + batchEvaluationId: 'eval-abc-123', + }); + }); + + it('calls deleteLocalBatchEvalRun with the id', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + expect(mockDeleteLocalBatchEvalRun).toHaveBeenCalledWith('eval-abc-123'); + }); + + it('outputs JSON on success with --json flag', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }); + + expect(mockLog).toHaveBeenCalledTimes(1); + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(true); + expect(output.batchEvaluationId).toBe('eval-abc-123'); + expect(output.status).toBe('DELETED'); + expect(output.localCliHistoryDeleted).toBe(true); + }); + + it('includes localCliHistoryDeleted: false in JSON when local file was not found', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + mockDeleteLocalBatchEvalRun.mockReturnValue(false); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.localCliHistoryDeleted).toBe(false); + }); + + it('includes localDeleteWarning in JSON and exits 0 when local delete throws', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + mockDeleteLocalBatchEvalRun.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(true); + expect(output.localCliHistoryDeleted).toBe(false); + expect(output.localDeleteWarning).toBe('Permission denied'); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('prints warning and exits 0 when local delete throws without --json', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + mockDeleteLocalBatchEvalRun.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(allOutput).toContain('Warning: could not clear local history: Permission denied'); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('prints human-readable success output without --json', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(allOutput).toContain('eval-abc-123'); + expect(allOutput).toContain('DELETED'); + }); + + it('does not call process.exit on success', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('outputs JSON error when deleteBatchEvaluation throws and --json is set', async () => { + mockDeleteBatchEvaluation.mockRejectedValue(new Error('Service unavailable')); + + await expect( + program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(false); + expect(output.error).toBe('Service unavailable'); + }); + + it('renders error via ink when deleteBatchEvaluation throws without --json', async () => { + mockDeleteBatchEvaluation.mockRejectedValue(new Error('Service unavailable')); + + await expect( + program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + expect(mockRender).toHaveBeenCalled(); + const renderArg = mockRender.mock.calls[0]![0]; + expect(JSON.stringify(renderArg)).toContain('Service unavailable'); + }); + + it('exits with code 1 on error', async () => { + mockDeleteBatchEvaluation.mockRejectedValue(new Error('fail')); + + await expect( + program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('calls requireProject', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + expect(mockRequireProject).toHaveBeenCalled(); + }); + }); + + describe('archive recommendation', () => { + it('rejects when --id is missing', async () => { + await expect(program.parseAsync(['archive', 'recommendation'], { from: 'user' })).rejects.toThrow(); + expect(mockDeleteRecommendation).not.toHaveBeenCalled(); + }); + + it('calls deleteRecommendation with the given id and auto-detected region', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + expect(mockDeleteRecommendation).toHaveBeenCalledWith({ + region: 'us-east-1', + recommendationId: 'rec-xyz-789', + }); + }); + + it('uses --region when provided', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--region', 'ap-southeast-1'], { + from: 'user', + }); + + expect(mockDeleteRecommendation).toHaveBeenCalledWith({ + region: 'ap-southeast-1', + recommendationId: 'rec-xyz-789', + }); + }); + + it('calls deleteLocalRecommendationRun with the id', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + expect(mockDeleteLocalRecommendationRun).toHaveBeenCalledWith('rec-xyz-789'); + }); + + it('outputs JSON on success with --json flag', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }); + + expect(mockLog).toHaveBeenCalledTimes(1); + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(true); + expect(output.recommendationId).toBe('rec-xyz-789'); + expect(output.status).toBe('DELETED'); + expect(output.localCliHistoryDeleted).toBe(true); + }); + + it('includes localCliHistoryDeleted: false in JSON when local file was not found', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + mockDeleteLocalRecommendationRun.mockReturnValue(false); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.localCliHistoryDeleted).toBe(false); + }); + + it('includes localDeleteWarning in JSON and exits 0 when local delete throws', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + mockDeleteLocalRecommendationRun.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(true); + expect(output.localCliHistoryDeleted).toBe(false); + expect(output.localDeleteWarning).toBe('Permission denied'); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('prints warning and exits 0 when local delete throws without --json', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + mockDeleteLocalRecommendationRun.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(allOutput).toContain('Warning: could not clear local history: Permission denied'); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('prints human-readable success output without --json', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(allOutput).toContain('rec-xyz-789'); + expect(allOutput).toContain('DELETED'); + }); + + it('does not call process.exit on success', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('outputs JSON error when deleteRecommendation throws and --json is set', async () => { + mockDeleteRecommendation.mockRejectedValue(new Error('Not found')); + + await expect( + program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(false); + expect(output.error).toBe('Not found'); + }); + + it('renders error via ink when deleteRecommendation throws without --json', async () => { + mockDeleteRecommendation.mockRejectedValue(new Error('Not found')); + + await expect( + program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + expect(mockRender).toHaveBeenCalled(); + const renderArg = mockRender.mock.calls[0]![0]; + expect(JSON.stringify(renderArg)).toContain('Not found'); + }); + + it('exits with code 1 on error', async () => { + mockDeleteRecommendation.mockRejectedValue(new Error('fail')); + + await expect( + program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('calls requireProject', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + expect(mockRequireProject).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/cli/commands/archive/command.tsx b/src/cli/commands/archive/command.tsx new file mode 100644 index 000000000..780883522 --- /dev/null +++ b/src/cli/commands/archive/command.tsx @@ -0,0 +1,93 @@ +import { deleteBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; +import { deleteRecommendation } from '../../aws/agentcore-recommendation'; +import { getErrorMessage } from '../../errors'; +import { deleteLocalBatchEvalRun, deleteLocalRecommendationRun } from '../../operations/archive/archive-storage'; +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; +import { requireProject } from '../../tui/guards'; +import { getRegion } from '../shared/region-utils'; +import type { Command } from '@commander-js/extra-typings'; +import { Text, render } from 'ink'; +import React from 'react'; + +async function executeArchive( + cliOptions: { id: string; region?: string; json?: boolean }, + config: { + serviceDelete: (id: string, region: string) => Promise; + localDelete: (id: string) => boolean; + getId: (result: T) => string; + successMessage: string; + } +): Promise { + requireProject(); + try { + const region = await getRegion(cliOptions.region); + const result = await config.serviceDelete(cliOptions.id, region); + + let localCliHistoryDeleted = false; + let localDeleteWarning: string | undefined; + try { + localCliHistoryDeleted = config.localDelete(cliOptions.id); + } catch (err) { + localDeleteWarning = getErrorMessage(err); + } + + if (cliOptions.json) { + console.log( + JSON.stringify({ + success: true, + ...result, + localCliHistoryDeleted, + ...(localDeleteWarning && { localDeleteWarning }), + }) + ); + } else { + console.log(`\n${config.successMessage}`); + console.log(`ID: ${config.getId(result)}`); + console.log(`Status: ${result.status}`); + if (localCliHistoryDeleted) console.log(`Local history cleared.`); + if (localDeleteWarning) console.log(`Warning: could not clear local history: ${localDeleteWarning}`); + console.log(''); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + render(Error: {getErrorMessage(error)}); + } + process.exit(1); + } +} + +export const registerArchive = (program: Command) => { + const archiveCmd = program.command('archive').description(COMMAND_DESCRIPTIONS.archive); + + archiveCmd + .command('batch-evaluation') + .description('[preview] Archive (delete) a batch evaluation on the service and clear local history') + .requiredOption('-i, --id ', 'Batch evaluation ID to archive') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--json', 'Output as JSON') + .action((cliOptions: { id: string; region?: string; json?: boolean }) => + executeArchive(cliOptions, { + serviceDelete: (id, region) => deleteBatchEvaluation({ region, batchEvaluationId: id }), + localDelete: deleteLocalBatchEvalRun, + getId: result => result.batchEvaluationId, + successMessage: 'Batch evaluation archived successfully', + }) + ); + + archiveCmd + .command('recommendation') + .description('[preview] Archive (delete) a recommendation on the service and clear local history') + .requiredOption('-i, --id ', 'Recommendation ID to archive') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--json', 'Output as JSON') + .action((cliOptions: { id: string; region?: string; json?: boolean }) => + executeArchive(cliOptions, { + serviceDelete: (id, region) => deleteRecommendation({ region, recommendationId: id }), + localDelete: deleteLocalRecommendationRun, + getId: result => result.recommendationId, + successMessage: 'Recommendation archived successfully', + }) + ); +}; diff --git a/src/cli/commands/archive/index.ts b/src/cli/commands/archive/index.ts new file mode 100644 index 000000000..ab2d7cd2d --- /dev/null +++ b/src/cli/commands/archive/index.ts @@ -0,0 +1 @@ +export { registerArchive } from './command'; diff --git a/src/cli/commands/config-bundle/command.tsx b/src/cli/commands/config-bundle/command.tsx new file mode 100644 index 000000000..ce72f2a4b --- /dev/null +++ b/src/cli/commands/config-bundle/command.tsx @@ -0,0 +1,347 @@ +import { + getConfigurationBundleVersion, + listConfigurationBundleVersions, + updateConfigurationBundle, +} from '../../aws/agentcore-config-bundles'; +import type { + ConfigurationBundleVersionSummary, + ListConfigurationBundleVersionsFilter, +} from '../../aws/agentcore-config-bundles'; +import { getErrorMessage } from '../../errors'; +import { deepDiff } from '../../operations/config-bundle/diff-versions'; +import { resolveBundleByName } from '../../operations/config-bundle/resolve-bundle'; +import { requireProject } from '../../tui/guards'; +import type { Command } from '@commander-js/extra-typings'; +import { Box, Text, render } from 'ink'; + +// ============================================================================ +// Helpers +// ============================================================================ + +function formatTimestamp(ts: string): string { + const num = Number(ts); + if (isNaN(num)) return ts; + // API returns epoch seconds; convert to ms if needed + const ms = num < 1e12 ? num * 1000 : num; + return new Date(ms) + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, 'Z'); +} + +async function resolveRegion(): Promise { + const { ConfigIO } = await import('../../../lib'); + const configIO = new ConfigIO(); + const targets = await configIO.resolveAWSDeploymentTargets(); + if (targets.length === 0) { + throw new Error('No AWS deployment targets configured. Run `agentcore deploy` first.'); + } + return targets[0]!.region; +} + +// ============================================================================ +// Version list +// ============================================================================ + +async function handleVersions(options: { + bundle: string; + branch?: string; + latestPerBranch?: boolean; + createdBy?: string; + region?: string; + json?: boolean; +}) { + const region = options.region ?? (await resolveRegion()); + const resolved = await resolveBundleByName(options.bundle, region); + + const filter: ListConfigurationBundleVersionsFilter = {}; + if (options.branch) filter.branchName = options.branch; + if (options.latestPerBranch) filter.latestPerBranch = true; + if (options.createdBy) filter.createdByName = options.createdBy; + const hasFilter = Object.keys(filter).length > 0; + + // Paginate to collect all versions + const allVersions: ConfigurationBundleVersionSummary[] = []; + let nextToken: string | undefined; + do { + const result = await listConfigurationBundleVersions({ + region, + bundleId: resolved.bundleId, + maxResults: 50, + nextToken, + ...(hasFilter && { filter }), + }); + allVersions.push(...result.versions); + nextToken = result.nextToken; + } while (nextToken); + + // Sort by creation time, newest first + allVersions.sort((a, b) => Number(b.versionCreatedAt) - Number(a.versionCreatedAt)); + + return { versions: allVersions, bundleName: options.bundle, bundleId: resolved.bundleId }; +} + +// ============================================================================ +// Diff +// ============================================================================ + +async function handleDiff(options: { bundle: string; from: string; to: string; region?: string }) { + const region = options.region ?? (await resolveRegion()); + const resolved = await resolveBundleByName(options.bundle, region); + + const [fromVersion, toVersion] = await Promise.all([ + getConfigurationBundleVersion({ region, bundleId: resolved.bundleId, versionId: options.from }), + getConfigurationBundleVersion({ region, bundleId: resolved.bundleId, versionId: options.to }), + ]); + + const diffs = deepDiff(fromVersion.components, toVersion.components); + + return { fromVersion, toVersion, diffs }; +} + +// ============================================================================ +// Command registration +// ============================================================================ + +export const registerConfigBundle = (program: Command) => { + const cmd = program + .command('config-bundle') + .alias('cb') + .description('[preview] Manage configuration bundles (use bundle name from agentcore.json, not the ID)'); + + // --- versions --- + cmd + .command('versions') + .description('List version history for a configuration bundle') + .requiredOption('--bundle ', 'Bundle name as defined in agentcore.json (e.g. "MyBundle")') + .option('--branch ', 'Filter by branch name') + .option('--latest-per-branch', 'Show only the latest version per branch') + .option('--created-by ', 'Filter by creator name (e.g. "user", "recommendation")') + .option('--region ', 'AWS region override') + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + bundle: string; + branch?: string; + latestPerBranch?: boolean; + createdBy?: string; + region?: string; + json?: boolean; + }) => { + requireProject(); + try { + const result = await handleVersions(cliOptions); + + if (cliOptions.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + if (result.versions.length === 0) { + render(No versions found for bundle "{cliOptions.bundle}".); + return; + } + + // Group by branch + const byBranch = new Map(); + for (const v of result.versions) { + const branch = v.lineageMetadata?.branchName ?? 'unknown'; + if (!byBranch.has(branch)) byBranch.set(branch, []); + byBranch.get(branch)!.push(v); + } + + render( + + + {result.bundleName} — {result.versions.length} version(s) + + + {[...byBranch.entries()].map(([branch, versions]) => ( + + + Branch: {branch} + + {versions.map((v, i) => { + const meta = v.lineageMetadata; + const creator = meta?.createdBy?.name ?? 'unknown'; + const message = meta?.commitMessage ?? ''; + const isLast = i === versions.length - 1; + const connector = isLast ? '└' : '├'; + return ( + + + {connector} {v.versionId}{' '} + {formatTimestamp(v.versionCreatedAt)}{' '} + {message && "{message}"} + + + {isLast ? ' ' : '│'} by: {creator} + {meta?.parentVersionIds?.length ? ( + (parent: {meta.parentVersionIds.join(', ')}) + ) : null} + + + ); + })} + + ))} + Use --json for complete output + + ); + } catch (error) { + render(Error: {getErrorMessage(error)}); + process.exit(1); + } + } + ); + + // --- diff --- + cmd + .command('diff') + .description('Diff two versions of a configuration bundle (get version IDs from `cb versions`)') + .requiredOption('--bundle ', 'Bundle name as defined in agentcore.json (e.g. "MyBundle")') + .requiredOption('--from ', 'Source version ID (from `config-bundle versions --json`)') + .requiredOption('--to ', 'Target version ID (from `config-bundle versions --json`)') + .option('--region ', 'AWS region override') + .option('--json', 'Output as JSON') + .action(async (cliOptions: { bundle: string; from: string; to: string; region?: string; json?: boolean }) => { + requireProject(); + try { + const result = await handleDiff(cliOptions); + + if (cliOptions.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + const fromMeta = result.fromVersion.lineageMetadata; + const toMeta = result.toVersion.lineageMetadata; + + render( + + + Diff: {result.fromVersion.versionId} → {result.toVersion.versionId} + + + From: {fromMeta?.commitMessage ?? '(no message)'} ({formatTimestamp(result.fromVersion.versionCreatedAt)}) + + + To: {toMeta?.commitMessage ?? '(no message)'} ({formatTimestamp(result.toVersion.versionCreatedAt)}) + + + {result.diffs.length === 0 ? ( + No differences found. + ) : ( + <> + {result.diffs.length} change(s): + + {result.diffs.map((d, i) => ( + + {d.path} + {d.type === 'added' && + {JSON.stringify(d.newValue)}} + {d.type === 'removed' && - {JSON.stringify(d.oldValue)}} + {d.type === 'changed' && ( + <> + - {JSON.stringify(d.oldValue)} + + {JSON.stringify(d.newValue)} + + )} + + ))} + + )} + + ); + } catch (error) { + render(Error: {getErrorMessage(error)}); + process.exit(1); + } + }); + + // --- create-branch --- + cmd + .command('create-branch') + .description('Create a new branch on an existing configuration bundle') + .requiredOption('--bundle ', 'Bundle name as defined in agentcore.json (e.g. "MyBundle")') + .requiredOption('--branch ', 'Name for the new branch') + .option('--from ', 'Parent version ID to branch from (defaults to latest version)') + .option('--commit-message ', 'Commit message for the branch point') + .option('--region ', 'AWS region override') + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + bundle: string; + branch: string; + from?: string; + commitMessage?: string; + region?: string; + json?: boolean; + }) => { + requireProject(); + try { + const region = cliOptions.region ?? (await resolveRegion()); + const resolved = await resolveBundleByName(cliOptions.bundle, region); + + // Determine parent version + let parentVersionId = cliOptions.from; + if (!parentVersionId) { + const versions = await listConfigurationBundleVersions({ + region, + bundleId: resolved.bundleId, + maxResults: 50, + }); + if (versions.versions.length === 0) { + throw new Error(`No versions found for bundle "${cliOptions.bundle}".`); + } + // Sort descending by creation time to get the latest version + const sorted = [...versions.versions].sort( + (a, b) => new Date(b.versionCreatedAt).getTime() - new Date(a.versionCreatedAt).getTime() + ); + parentVersionId = sorted[0]!.versionId; + } + + // Get the parent version's components to carry forward + const parentVersion = await getConfigurationBundleVersion({ + region, + bundleId: resolved.bundleId, + versionId: parentVersionId, + }); + + const result = await updateConfigurationBundle({ + region, + bundleId: resolved.bundleId, + components: parentVersion.components, + parentVersionIds: [parentVersionId], + branchName: cliOptions.branch, + commitMessage: cliOptions.commitMessage ?? `Create branch ${cliOptions.branch}`, + }); + + if (cliOptions.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + render( + + + Branch "{cliOptions.branch}" created on bundle "{cliOptions.bundle}" + + + Version: {result.versionId} + + Parent: {parentVersionId} + + ); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + render(Error: {getErrorMessage(error)}); + } + process.exit(1); + } + } + ); + + return cmd; +}; diff --git a/src/cli/commands/config-bundle/index.ts b/src/cli/commands/config-bundle/index.ts new file mode 100644 index 000000000..2ebcc4c68 --- /dev/null +++ b/src/cli/commands/config-bundle/index.ts @@ -0,0 +1 @@ +export { registerConfigBundle } from './command'; diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index dbfc215d7..a00397f38 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -11,6 +11,7 @@ import type { import { getErrorMessage } from '../../errors'; import { checkCreateDependencies } from '../../external-requirements'; import { initGitRepo, setupPythonProject, writeEnvFile, writeGitignore } from '../../operations'; +import { createConfigBundleForAgent } from '../../operations/agent/config-bundle-defaults'; import { mapGenerateConfigToRenderConfig, mapModelProviderToIdentityProviders, @@ -131,6 +132,7 @@ export interface CreateWithAgentOptions { idleTimeout?: number; maxLifetime?: number; sessionStorageMountPath?: string; + withConfigBundle?: boolean; skipGit?: boolean; skipInstall?: boolean; skipPythonSetup?: boolean; @@ -156,6 +158,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P idleTimeout, maxLifetime: maxLifetimeOpt, sessionStorageMountPath, + withConfigBundle, skipGit, skipInstall, skipPythonSetup, @@ -245,6 +248,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P ...(idleTimeout !== undefined && { idleRuntimeSessionTimeout: idleTimeout }), ...(maxLifetimeOpt !== undefined && { maxLifetime: maxLifetimeOpt }), ...(sessionStorageMountPath && { sessionStorageMountPath }), + ...(withConfigBundle && { withConfigBundle }), }; // Resolve credential strategy FIRST (new project has no existing credentials) @@ -286,6 +290,11 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P } onProgress?.('Add agent to project', 'done'); + // Auto-create config bundle when opted in + if (withConfigBundle) { + await createConfigBundleForAgent(agentName, configBaseDir); + } + // Set up Python environment if needed (unless skipped) if (language === 'Python' && !skipPythonSetup && !skipInstall) { onProgress?.('Set up Python environment', 'start'); diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index ac9d4b3ae..42c8c7403 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -150,6 +150,7 @@ async function handleCreateCLI(options: CreateOptions): Promise { idleTimeout: options.idleTimeout ? Number(options.idleTimeout) : undefined, maxLifetime: options.maxLifetime ? Number(options.maxLifetime) : undefined, sessionStorageMountPath: options.sessionStorageMountPath, + withConfigBundle: options.withConfigBundle, skipGit: options.skipGit, skipInstall: options.skipInstall, skipPythonSetup: options.skipPythonSetup, @@ -212,6 +213,7 @@ export const registerCreate = (program: Command) => { '--session-storage-mount-path ', 'Absolute mount path for session filesystem storage under /mnt (e.g. /mnt/data) [non-interactive]' ) + .option('--with-config-bundle', 'Create a config bundle wired into the agent template [preview] [non-interactive]') .option('--output-dir ', 'Output directory (default: current directory) [non-interactive]') .option('--skip-git', 'Skip git repository initialization [non-interactive]') .option('--skip-python-setup', 'Skip Python virtual environment setup [non-interactive]') diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index f870ec1fc..f762a36eb 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -19,6 +19,7 @@ export interface CreateOptions extends VpcOptions { idleTimeout?: number | string; maxLifetime?: number | string; sessionStorageMountPath?: string; + withConfigBundle?: boolean; outputDir?: string; skipGit?: boolean; skipPythonSetup?: boolean; diff --git a/src/cli/commands/deploy/__tests__/deploy.test.ts b/src/cli/commands/deploy/__tests__/deploy.test.ts index 5545fee2a..ea05fe684 100644 --- a/src/cli/commands/deploy/__tests__/deploy.test.ts +++ b/src/cli/commands/deploy/__tests__/deploy.test.ts @@ -6,19 +6,14 @@ import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe('deploy --help', () => { - it('shows verbose option', async () => { - const result = await runCLI(['deploy', '--help'], process.cwd()); - expect(result.exitCode).toBe(0); - expect(result.stdout.includes('--verbose'), 'Should show --verbose option').toBeTruthy(); - expect(result.stdout.includes('resource-level'), 'Should describe resource-level events').toBeTruthy(); - }); - it('shows all deploy options', async () => { const result = await runCLI(['deploy', '--help'], process.cwd()); + expect(result.exitCode).toBe(0); expect(result.stdout.includes('--yes')).toBeTruthy(); expect(result.stdout.includes('--verbose')).toBeTruthy(); expect(result.stdout.includes('--json')).toBeTruthy(); expect(result.stdout.includes('--dry-run')).toBeTruthy(); + expect(result.stdout.includes('resource-level'), 'Should describe resource-level events').toBeTruthy(); }); }); diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index ed88c1dfe..acae6fb03 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -33,6 +33,13 @@ import { validateProject, } from '../../operations/deploy'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; +import { deleteOrphanedABTests, setupABTests } from '../../operations/deploy/post-deploy-ab-tests'; +import { + resolveConfigBundleComponentKeys, + setupConfigBundles, +} from '../../operations/deploy/post-deploy-config-bundles'; +import { setupHttpGateways } from '../../operations/deploy/post-deploy-http-gateways'; +import { enableOnlineEvalConfigs } from '../../operations/deploy/post-deploy-online-evals'; import type { DeployResult } from './types'; export interface ValidatedDeployOptions { @@ -390,8 +397,12 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise c.name); - const onlineEvalConfigs = parseOnlineEvalOutputs(outputs, onlineEvalNames); + const onlineEvalSpecs = (context.projectSpec.onlineEvalConfigs ?? []).map(c => ({ + name: c.name, + agent: c.agent, + endpoint: c.endpoint, + })); + const onlineEvalConfigs = parseOnlineEvalOutputs(outputs, onlineEvalSpecs); // Parse policy engine outputs const policyEngineSpecs = context.projectSpec.policyEngines ?? []; @@ -427,7 +438,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise undefined); - const deployedState = buildDeployedState({ + let deployedState = buildDeployedState({ targetName: target.name, stackName, agents, @@ -462,6 +473,161 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise !previouslyDeployedOnlineEvals[c.name]); + if (newOnlineEvalFullSpecs.length > 0 && Object.keys(deployedOnlineEvalConfigs).length > 0) { + const enableResult = await enableOnlineEvalConfigs({ + region: target.region, + onlineEvalConfigs: newOnlineEvalFullSpecs, + deployedOnlineEvalConfigs, + }); + + if (enableResult.hasErrors) { + const errors = enableResult.results.filter(r => r.status === 'error'); + const errorMessages = errors.map(err => `"${err.configName}": ${err.error}`).join('; '); + logger.log(`Online eval enable warnings: ${errorMessages}`, 'warn'); + postDeployWarnings.push(...errors.map(err => `Online eval "${err.configName}": ${err.error}`)); + } + } + + // Pre-gateway: Delete orphaned AB tests so their gateway rules are cleaned up + // before we attempt to delete orphaned HTTP gateways. + const existingABTestsForCleanup = deployedState.targets?.[target.name]?.resources?.abTests; + if (existingABTestsForCleanup && Object.keys(existingABTestsForCleanup).length > 0) { + const deleteResult = await deleteOrphanedABTests({ + region: target.region, + projectSpec: context.projectSpec, + existingABTests: existingABTestsForCleanup, + }); + + if (deleteResult.hasErrors) { + const errors = deleteResult.results.filter(r => r.status === 'error'); + const errorMessages = errors.map(err => `"${err.testName}": ${err.error}`).join('; '); + logger.log(`AB test orphan cleanup warnings: ${errorMessages}`, 'warn'); + postDeployWarnings.push(...errors.map(err => `AB test "${err.testName}": ${err.error}`)); + } + + // Surface warnings (e.g., "AB test was stopped before deletion") + for (const r of deleteResult.results) { + if (r.warning) { + logger.log(r.warning, 'warn'); + postDeployWarnings.push(r.warning); + } + } + + // Update deployed state to remove deleted AB tests + if (deleteResult.results.some(r => r.status === 'deleted')) { + const updatedState = await configIO.readDeployedState().catch(() => deployedState); + const targetResources = updatedState.targets[target.name]?.resources; + if (targetResources?.abTests) { + for (const r of deleteResult.results) { + if (r.status === 'deleted') delete targetResources.abTests[r.testName]; + } + await configIO.writeDeployedState(updatedState); + deployedState = updatedState; + } + } + } + + // Post-deploy: Create/update HTTP gateways for AB tests (must run BEFORE config bundles + // because config bundle component keys may reference gateway ARNs) + const httpGatewaySpecs = context.projectSpec.httpGateways ?? []; + const existingHttpGateways = deployedState.targets?.[target.name]?.resources?.httpGateways; + if (httpGatewaySpecs.length > 0 || Object.keys(existingHttpGateways ?? {}).length > 0) { + const deployedResources = deployedState.targets?.[target.name]?.resources; + const httpGatewayResult = await setupHttpGateways({ + region: target.region, + projectName: context.projectSpec.name, + projectSpec: context.projectSpec, + existingHttpGateways, + deployedResources, + }); + + // Always merge HTTP gateway state (even if empty, to clear deleted gateways) + const updatedState = await configIO.readDeployedState().catch(() => deployedState); + const targetResources = updatedState.targets[target.name]?.resources; + if (targetResources) { + targetResources.httpGateways = httpGatewayResult.httpGateways; + await configIO.writeDeployedState(updatedState); + deployedState = updatedState; + } + + if (httpGatewayResult.hasErrors) { + const errors = httpGatewayResult.results.filter(r => r.status === 'error'); + const errorMessages = errors.map(err => `"${err.gatewayName}": ${err.error}`).join('; '); + logger.log(`HTTP gateway setup warnings: ${errorMessages}`, 'warn'); + postDeployWarnings.push(...errors.map(err => `HTTP gateway "${err.gatewayName}": ${err.error}`)); + } + } + + // Post-deploy: Create/update configuration bundles + const configBundleSpecs = context.projectSpec.configBundles ?? []; + if (configBundleSpecs.length > 0) { + // Resolve component key placeholders (e.g., {{gateway:name}} → real ARN) + const resolvedProjectSpec = resolveConfigBundleComponentKeys(context.projectSpec, deployedState, target.name); + + const existingConfigBundles = deployedState.targets?.[target.name]?.resources?.configBundles; + const configBundleResult = await setupConfigBundles({ + region: target.region, + projectSpec: resolvedProjectSpec, + existingBundles: existingConfigBundles, + }); + + // Merge config bundle state into deployed state + if (Object.keys(configBundleResult.configBundles).length > 0) { + const updatedState = await configIO.readDeployedState().catch(() => deployedState); + const targetResources = updatedState.targets[target.name]?.resources; + if (targetResources) { + targetResources.configBundles = configBundleResult.configBundles; + await configIO.writeDeployedState(updatedState); + deployedState = updatedState; + } + } + + if (configBundleResult.hasErrors) { + const errors = configBundleResult.results.filter(r => r.status === 'error'); + const errorMessages = errors.map(err => `"${err.bundleName}": ${err.error}`).join('; '); + logger.log(`Config bundle setup warnings: ${errorMessages}`, 'warn'); + postDeployWarnings.push(...errors.map(err => `Config bundle "${err.bundleName}": ${err.error}`)); + } + } + + // Post-deploy: Create/update AB tests + const abTestSpecs = context.projectSpec.abTests ?? []; + if (abTestSpecs.length > 0) { + const existingABTests = deployedState.targets?.[target.name]?.resources?.abTests; + const deployedResources = deployedState.targets?.[target.name]?.resources; + const abTestResult = await setupABTests({ + region: target.region, + projectSpec: context.projectSpec, + existingABTests, + deployedResources, + }); + + // Merge AB test state into deployed state + if (Object.keys(abTestResult.abTests).length > 0) { + const updatedState = await configIO.readDeployedState().catch(() => deployedState); + const targetResources = updatedState.targets[target.name]?.resources; + if (targetResources) { + targetResources.abTests = abTestResult.abTests; + await configIO.writeDeployedState(updatedState); + } + } + + if (abTestResult.hasErrors) { + const errors = abTestResult.results.filter(r => r.status === 'error'); + const errorMessages = errors.map(err => `"${err.testName}": ${err.error}`).join('; '); + logger.log(`AB test setup warnings: ${errorMessages}`, 'warn'); + postDeployWarnings.push(...errors.map(err => `AB test "${err.testName}": ${err.error}`)); + } + } + // Post-deploy: Enable CloudWatch Transaction Search (non-blocking, silent) const nextSteps = agentNames.length > 0 ? [...AGENT_NEXT_STEPS] : [...MEMORY_ONLY_NEXT_STEPS]; const notes: string[] = []; @@ -495,6 +661,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? postDeployWarnings : undefined, }; } catch (err: unknown) { logger.log(getErrorMessage(err), 'error'); @@ -507,3 +674,13 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { } } + if (result.postDeployWarnings && result.postDeployWarnings.length > 0) { + console.log('\n⚠ Post-deploy warnings:'); + for (const warning of result.postDeployWarnings) { + console.log(` ${warning}`); + } + } + if (result.notes && result.notes.length > 0) { for (const note of result.notes) { console.log(`\nNote: ${note}`); @@ -124,7 +131,8 @@ async function handleDeployCLI(options: DeployOptions): Promise { } } - process.exit(result.success ? 0 : 1); + const hasPostDeployWarnings = result.success && result.postDeployWarnings && result.postDeployWarnings.length > 0; + process.exit(result.success ? (hasPostDeployWarnings ? 2 : 0) : 1); } export const registerDeploy = (program: Command) => { diff --git a/src/cli/commands/deploy/types.ts b/src/cli/commands/deploy/types.ts index 16d4f39a2..44cdc7847 100644 --- a/src/cli/commands/deploy/types.ts +++ b/src/cli/commands/deploy/types.ts @@ -16,6 +16,7 @@ export interface DeployResult { logPath?: string; nextSteps?: string[]; notes?: string[]; + postDeployWarnings?: string[]; error?: string; } diff --git a/src/cli/commands/dev/__tests__/dev.test.ts b/src/cli/commands/dev/__tests__/dev.test.ts index 3947f6784..5b86e647b 100644 --- a/src/cli/commands/dev/__tests__/dev.test.ts +++ b/src/cli/commands/dev/__tests__/dev.test.ts @@ -74,12 +74,5 @@ describe('dev command', () => { expect(result.exitCode).toBe(1); }); - - it('stream flag is documented in help', async () => { - const result = await runCLI(['dev', '--help'], process.cwd()); - - expect(result.exitCode).toBe(0); - expect(result.stdout.includes('--stream'), 'Should show --stream option').toBeTruthy(); - }); }); }); 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/commands/help/command.tsx b/src/cli/commands/help/command.tsx index 48d771854..338684d7e 100644 --- a/src/cli/commands/help/command.tsx +++ b/src/cli/commands/help/command.tsx @@ -1,3 +1,4 @@ +import { TelemetryClientAccessor } from '../../telemetry/client-accessor.js'; import type { Command } from '@commander-js/extra-typings'; const MODES_HELP = ` @@ -41,15 +42,23 @@ export const registerHelp = (program: Command) => { const helpCmd = program .command('help') .description('Display help topics') - .action(() => { - console.log('Available help topics: modes'); - console.log('Run `agentcore help ` for details.'); + .action(async () => { + const client = await TelemetryClientAccessor.get(); + await client.withCommandRun('help', () => { + console.log('Available help topics: modes'); + console.log('Run `agentcore help ` for details.'); + return {}; + }); }); helpCmd .command('modes') .description('Explain interactive vs non-interactive modes') - .action(() => { - console.log(MODES_HELP); + .action(async () => { + const client = await TelemetryClientAccessor.get(); + await client.withCommandRun('help.modes', () => { + console.log(MODES_HELP); + return {}; + }); }); }; diff --git a/src/cli/commands/import/__tests__/import-gateway-cfn.test.ts b/src/cli/commands/import/__tests__/import-gateway-cfn.test.ts new file mode 100644 index 000000000..a402cc2e2 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-cfn.test.ts @@ -0,0 +1,279 @@ +/** + * Tests for buildCredentialArnMap and CFN template resource matching logic + * used in the gateway import flow. + */ +import { buildCredentialArnMap } from '../import-gateway'; +import type { CfnTemplate } from '../template-utils'; +import { findLogicalIdByProperty, findLogicalIdsByType } from '../template-utils'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// Part 1: buildCredentialArnMap +// ============================================================================ + +describe('buildCredentialArnMap', () => { + it('reads credentials from deployed state', async () => { + const configIO = { + readDeployedState: () => + Promise.resolve({ + targets: { + default: { + resources: { + credentials: { + myCred: { credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential/myCred' }, + }, + }, + }, + }, + }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(1); + expect(map.get('arn:aws:bedrock:us-east-1:123456789012:credential/myCred')).toBe('myCred'); + }); + + it('handles multiple credentials', async () => { + const configIO = { + readDeployedState: () => + Promise.resolve({ + targets: { + default: { + resources: { + credentials: { + oauthCred: { credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential/oauth' }, + apiKeyCred: { credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential/apikey' }, + }, + }, + }, + }, + }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(2); + expect(map.get('arn:aws:bedrock:us-east-1:123456789012:credential/oauth')).toBe('oauthCred'); + expect(map.get('arn:aws:bedrock:us-east-1:123456789012:credential/apikey')).toBe('apiKeyCred'); + }); + + it('returns empty map when readDeployedState throws', async () => { + const configIO = { + readDeployedState: () => Promise.reject(new Error('No deployed state file')), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(0); + }); + + it('returns empty map when no credentials key exists', async () => { + const configIO = { + readDeployedState: () => + Promise.resolve({ + targets: { + default: { + resources: {}, + }, + }, + }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(0); + }); + + it('returns empty map when targets is empty', async () => { + const configIO = { + readDeployedState: () => Promise.resolve({ targets: {} }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(0); + }); +}); + +// ============================================================================ +// Part 2: CFN template matching (findLogicalIdByProperty, findLogicalIdsByType) +// ============================================================================ + +describe('findLogicalIdByProperty – gateway scenarios', () => { + it('finds gateway by Name = projectName-localName', () => { + const template: CfnTemplate = { + Resources: { + MyGatewayResource: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'myProject-myGateway', + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myProject-myGateway'); + expect(result).toBe('MyGatewayResource'); + }); + + it('finds gateway by resourceName (localName only) as fallback', () => { + const template: CfnTemplate = { + Resources: { + GatewayA: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'someOtherName', + }, + }, + GatewayB: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'myGateway', + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + expect(result).toBe('GatewayB'); + }); + + it('finds target by Name property', () => { + const template: CfnTemplate = { + Resources: { + TargetLogical1: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { + Name: 'mcpTarget', + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::GatewayTarget', 'Name', 'mcpTarget'); + expect(result).toBe('TargetLogical1'); + }); +}); + +describe('findLogicalIdsByType – gateway fallback', () => { + it('returns the single gateway when name-based lookup fails', () => { + const template: CfnTemplate = { + Resources: { + OnlyGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'completely-different-name', + }, + }, + SomeRole: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + // Name-based lookup fails + const byName = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myProject-myGateway'); + expect(byName).toBeUndefined(); + + // Type-based fallback returns the single gateway + const allGateways = findLogicalIdsByType(template, 'AWS::BedrockAgentCore::Gateway'); + expect(allGateways).toHaveLength(1); + expect(allGateways[0]).toBe('OnlyGateway'); + }); + + it('returns single target for fallback when one target and one in targetIdMap', () => { + const template: CfnTemplate = { + Resources: { + OnlyTarget: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { + Name: 'different-name', + }, + }, + }, + }; + + const allTargets = findLogicalIdsByType(template, 'AWS::BedrockAgentCore::GatewayTarget'); + expect(allTargets).toHaveLength(1); + expect(allTargets[0]).toBe('OnlyTarget'); + }); + + it('returns multiple targets preventing fallback when more than one exists', () => { + const template: CfnTemplate = { + Resources: { + Target1: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'targetA' }, + }, + Target2: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'targetB' }, + }, + }, + }; + + const allTargets = findLogicalIdsByType(template, 'AWS::BedrockAgentCore::GatewayTarget'); + expect(allTargets).toHaveLength(2); + + // Name-based matching must succeed — fallback is not safe with multiple targets + // Simulate the import-gateway logic: only fallback if allTargets.length === 1 && targetIdMap.size === 1 + const targetIdMap = new Map([ + ['targetA', 'tid-1'], + ['targetB', 'tid-2'], + ]); + const shouldFallback = allTargets.length === 1 && targetIdMap.size === 1; + expect(shouldFallback).toBe(false); + }); +}); + +// ============================================================================ +// Part 3: Fn::Join / Fn::Sub patterns in findLogicalIdByProperty +// ============================================================================ + +describe('findLogicalIdByProperty – intrinsic function patterns', () => { + it('matches Fn::Join Name via regex second pass', () => { + const template: CfnTemplate = { + Resources: { + JoinGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: { 'Fn::Join': ['-', ['prefix', 'myGateway']] }, + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + expect(result).toBe('JoinGateway'); + }); + + it('avoids false substring matches with regex boundary check', () => { + const template: CfnTemplate = { + Resources: { + WrongGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: { 'Fn::Join': ['-', ['prefix', 'myGateway_v2']] }, + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + // "myGateway" should NOT match "myGateway_v2" due to boundary check + expect(result).toBeUndefined(); + }); + + it('matches Fn::Sub Name via regex second pass', () => { + const template: CfnTemplate = { + Resources: { + SubGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: { 'Fn::Sub': '${AWS::StackName}-myGateway' }, + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + expect(result).toBe('SubGateway'); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-gateway-flow.test.ts b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts new file mode 100644 index 000000000..183a6e63f --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts @@ -0,0 +1,502 @@ +/** + * Tests for handleImportGateway — the main gateway import flow. + * + * Covers: + * - Happy path: successful import with --arn + * - Rollback on pipeline failure and noResources + * - Duplicate detection (name + deployed state ID) + * - Name validation (invalid name, --name override) + * - Auto-select / multi-gateway / no gateways + * - Skipped targets warning + * - Non-READY gateway warning + */ +import { handleImportGateway } from '../import-gateway'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Hoisted mock fns ──────────────────────────────────────────────────────── + +const { + mockFindConfigRoot, + mockConfigIOInstance, + MockConfigIOClass, + mockValidateAwsCredentials, + mockDetectAccount, + mockGetGatewayDetail, + mockListAllGateways, + mockListAllGatewayTargets, + mockGetGatewayTargetDetail, + mockExecuteCdkImportPipeline, +} = vi.hoisted(() => { + const inst = { + readProjectSpec: vi.fn(), + writeProjectSpec: vi.fn(), + readAWSDeploymentTargets: vi.fn(), + readDeployedState: vi.fn(), + writeDeployedState: vi.fn(), + }; + return { + mockFindConfigRoot: vi.fn(), + mockConfigIOInstance: inst, + MockConfigIOClass: vi.fn(function (this: any) { + Object.assign(this, inst); + return this; + }), + mockValidateAwsCredentials: vi.fn(), + mockDetectAccount: vi.fn(), + mockGetGatewayDetail: vi.fn(), + mockListAllGateways: vi.fn(), + mockListAllGatewayTargets: vi.fn(), + mockGetGatewayTargetDetail: vi.fn(), + mockExecuteCdkImportPipeline: vi.fn(), + }; +}); + +// ── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock('../../../../lib', () => ({ + APP_DIR: 'app', + ConfigIO: MockConfigIOClass, + findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), +})); + +vi.mock('../../../aws/account', () => ({ + validateAwsCredentials: (...args: unknown[]) => mockValidateAwsCredentials(...args), + detectAccount: (...args: unknown[]) => mockDetectAccount(...args), +})); + +vi.mock('../../../aws/agentcore-control', () => ({ + getGatewayDetail: (...args: unknown[]) => mockGetGatewayDetail(...args), + listAllGateways: (...args: unknown[]) => mockListAllGateways(...args), + listAllGatewayTargets: (...args: unknown[]) => mockListAllGatewayTargets(...args), + getGatewayTargetDetail: (...args: unknown[]) => mockGetGatewayTargetDetail(...args), +})); + +vi.mock('../../../logging', () => ({ + ExecLogger: class MockExecLogger { + startStep = vi.fn(); + endStep = vi.fn(); + log = vi.fn(); + finalize = vi.fn(); + getRelativeLogPath = vi.fn().mockReturnValue('agentcore/.cli/logs/import/import-gateway-mock.log'); + logFilePath = 'agentcore/.cli/logs/import/import-gateway-mock.log'; + }, +})); + +vi.mock('../import-pipeline', () => ({ + executeCdkImportPipeline: (...args: unknown[]) => mockExecuteCdkImportPipeline(...args), +})); + +// ── Test Fixtures ──────────────────────────────────────────────────────────── + +const ACCOUNT = '123456789012'; +const REGION = 'us-east-1'; +const GATEWAY_ID = 'gw-abc123'; +const GATEWAY_ARN = `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT}:gateway/${GATEWAY_ID}`; +const GATEWAY_NAME = 'MyGateway'; + +function makeProjectSpec(gateways: any[] = []) { + return { + name: 'TestProject', + version: 1, + runtimes: [], + memories: [], + credentials: [], + agentCoreGateways: gateways, + }; +} + +function makeGatewayDetail(overrides?: Record) { + return { + gatewayId: GATEWAY_ID, + gatewayArn: GATEWAY_ARN, + name: GATEWAY_NAME, + status: 'READY', + authorizerType: 'NONE', + ...overrides, + }; +} + +function makeTargetSummary(id: string, name: string) { + return { targetId: id, name, status: 'READY' }; +} + +function makeTargetDetail(id: string, name: string, endpoint: string) { + return { + targetId: id, + name, + status: 'READY', + targetConfiguration: { + mcp: { + mcpServer: { endpoint }, + }, + }, + }; +} + +// ── Common setup ───────────────────────────────────────────────────────────── + +function setupCommonMocks() { + mockFindConfigRoot.mockReturnValue('/tmp/project/agentcore'); + + mockConfigIOInstance.readAWSDeploymentTargets.mockResolvedValue([ + { name: 'default', account: ACCOUNT, region: REGION }, + ]); + + mockValidateAwsCredentials.mockResolvedValue(undefined); + mockDetectAccount.mockResolvedValue(ACCOUNT); + + mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec()); + mockConfigIOInstance.writeProjectSpec.mockResolvedValue(undefined); + mockConfigIOInstance.readDeployedState.mockResolvedValue({ targets: {} }); + + mockGetGatewayDetail.mockResolvedValue(makeGatewayDetail()); + mockListAllGateways.mockResolvedValue([ + { gatewayId: GATEWAY_ID, name: GATEWAY_NAME, status: 'READY', authorizerType: 'NONE' }, + ]); + mockListAllGatewayTargets.mockResolvedValue([makeTargetSummary('tgt-1', 'target1')]); + mockGetGatewayTargetDetail.mockResolvedValue(makeTargetDetail('tgt-1', 'target1', 'https://example.com/mcp')); + + mockExecuteCdkImportPipeline.mockResolvedValue({ success: true }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('handleImportGateway', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupCommonMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ── Happy path ────────────────────────────────────────────────────────── + + describe('Happy path', () => { + it('successfully imports a gateway with --arn', async () => { + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(true); + expect(result.resourceId).toBe(GATEWAY_ID); + expect(result.resourceType).toBe('gateway'); + expect(result.resourceName).toBe(GATEWAY_NAME); + + // writeProjectSpec called once with gateway added + expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1); + const writtenSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.agentCoreGateways).toHaveLength(1); + expect(writtenSpec.agentCoreGateways[0].name).toBe(GATEWAY_NAME); + expect(writtenSpec.agentCoreGateways[0].targets).toHaveLength(1); + }); + }); + + // ── Rollback ──────────────────────────────────────────────────────────── + + describe('Rollback', () => { + it('rolls back config on pipeline failure', async () => { + mockExecuteCdkImportPipeline.mockResolvedValue({ success: false, error: 'Phase 2 failed' }); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Phase 2 failed'); + + // First call = write merged config, second call = rollback + expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); + const rollbackSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; + expect(rollbackSpec.agentCoreGateways).toEqual([]); + }); + + it('rolls back config on noResources (logical ID not found)', async () => { + mockExecuteCdkImportPipeline.mockResolvedValue({ success: true, noResources: true }); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Could not find logical ID'); + + // First call = write merged config, second call = rollback + expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); + const rollbackSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; + expect(rollbackSpec.agentCoreGateways).toEqual([]); + }); + }); + + // ── Duplicate detection ───────────────────────────────────────────────── + + describe('Duplicate detection', () => { + it('rejects when gateway name already exists in project', async () => { + mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec([{ name: GATEWAY_NAME, targets: [] }])); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toContain('already exists'); + expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); + }); + + it('re-imports gateway already in deployed state but missing from agentcore.json', async () => { + mockConfigIOInstance.readDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + mcp: { + gateways: { + ExistingGateway: { gatewayId: GATEWAY_ID }, + }, + }, + }, + }, + }, + }); + mockExecuteCdkImportPipeline.mockResolvedValue({ success: true, noResources: true }); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(true); + expect(result.resourceName).toBe('ExistingGateway'); + expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1); + expect(mockExecuteCdkImportPipeline).toHaveBeenCalled(); + }); + + it('re-import uses --name override instead of deployed-state name', async () => { + mockConfigIOInstance.readDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + mcp: { + gateways: { + ExistingGateway: { gatewayId: GATEWAY_ID }, + }, + }, + }, + }, + }, + }); + mockExecuteCdkImportPipeline.mockResolvedValue({ success: true, noResources: true }); + + const result = await handleImportGateway({ arn: GATEWAY_ARN, name: 'myCustomName' }); + + expect(result.success).toBe(true); + expect(result.resourceName).toBe('myCustomName'); + expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1); + expect(mockExecuteCdkImportPipeline).toHaveBeenCalled(); + }); + + it('rejects when gateway name AND ID both already exist in project', async () => { + mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec([{ name: GATEWAY_NAME, targets: [] }])); + mockConfigIOInstance.readDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + mcp: { + gateways: { + [GATEWAY_NAME]: { gatewayId: GATEWAY_ID }, + }, + }, + }, + }, + }, + }); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toContain('already exists'); + }); + }); + + // ── Name validation ───────────────────────────────────────────────────── + + describe('Name validation', () => { + 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(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); + }); + + it('uses --name override with original resourceName preserved', async () => { + const result = await handleImportGateway({ arn: GATEWAY_ARN, name: 'myCustomName' }); + + expect(result.success).toBe(true); + expect(result.resourceName).toBe('myCustomName'); + + const writtenSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[0]![0]; + const addedGateway = writtenSpec.agentCoreGateways[0]; + expect(addedGateway.name).toBe('myCustomName'); + expect(addedGateway.resourceName).toBe(GATEWAY_NAME); + }); + }); + + // ── Auto-select / multi-gateway ───────────────────────────────────────── + + describe('Auto-select / multi-gateway', () => { + it('auto-selects when only 1 gateway exists and no --arn', async () => { + mockListAllGateways.mockResolvedValue([ + { gatewayId: GATEWAY_ID, name: GATEWAY_NAME, status: 'READY', authorizerType: 'NONE' }, + ]); + + const result = await handleImportGateway({}); + + expect(result.success).toBe(true); + expect(result.resourceId).toBe(GATEWAY_ID); + expect(mockGetGatewayDetail).toHaveBeenCalledWith({ region: REGION, gatewayId: GATEWAY_ID }); + }); + + it('fails when multiple gateways exist and no --arn', async () => { + mockListAllGateways.mockResolvedValue([ + { gatewayId: 'gw-1', name: 'Gateway1', status: 'READY', authorizerType: 'NONE' }, + { gatewayId: 'gw-2', name: 'Gateway2', status: 'READY', authorizerType: 'NONE' }, + ]); + + const result = await handleImportGateway({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('Multiple gateways found'); + }); + + it('fails when no gateways exist and no --arn', async () => { + mockListAllGateways.mockResolvedValue([]); + + const result = await handleImportGateway({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('No gateways found'); + }); + }); + + // ── Target mapping ────────────────────────────────────────────────────── + + describe('Target mapping', () => { + it('emits warning when some targets cannot be mapped', async () => { + // 2 target summaries, but one has no MCP config so it will be skipped + mockListAllGatewayTargets.mockResolvedValue([ + makeTargetSummary('tgt-1', 'goodTarget'), + makeTargetSummary('tgt-2', 'badTarget'), + ]); + + mockGetGatewayTargetDetail.mockImplementation((opts: { targetId: string }) => { + if (opts.targetId === 'tgt-1') { + return Promise.resolve(makeTargetDetail('tgt-1', 'goodTarget', 'https://example.com/mcp')); + } + // No MCP config => will be skipped + return Promise.resolve({ + targetId: 'tgt-2', + name: 'badTarget', + status: 'READY', + targetConfiguration: {}, + }); + }); + + const progressMessages: string[] = []; + const result = await handleImportGateway({ + arn: GATEWAY_ARN, + onProgress: (msg: string) => progressMessages.push(msg), + }); + + expect(result.success).toBe(true); + + // Verify warning about unmapped targets + expect(progressMessages.some(m => m.includes('1 target(s) could not be mapped'))).toBe(true); + }); + + it('emits warning for non-READY gateway but continues', async () => { + mockGetGatewayDetail.mockResolvedValue(makeGatewayDetail({ status: 'CREATING' })); + + const progressMessages: string[] = []; + const result = await handleImportGateway({ + arn: GATEWAY_ARN, + onProgress: (msg: string) => progressMessages.push(msg), + }); + + expect(result.success).toBe(true); + expect(progressMessages.some(m => m.includes('CREATING') && m.includes('not READY'))).toBe(true); + }); + }); + + // ── Re-import into existing stack (logical-ID collision) ──────────────── + + describe('buildResourcesToImport — excludes already-deployed logical IDs', () => { + it('skips deployed targets with the same Name when importing a new gateway', async () => { + await handleImportGateway({ arn: GATEWAY_ARN }); + + const pipelineInput = mockExecuteCdkImportPipeline.mock.calls[0]![0]; + const build = pipelineInput.buildResourcesToImport; + + // Deployed template already contains a gateway + target (from a prior import) + // whose target Name collides with the one being newly imported. + const deployedTemplate = { + Resources: { + OldGatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + OldTargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + }, + }; + + // Synth template contains both old and new resources (same names). + const synthTemplate = { + Resources: { + OldGatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + OldTargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + NewGatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + NewTargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + }, + }; + + const resources = build(synthTemplate, deployedTemplate); + + const logicalIds = resources.map((r: { logicalResourceId: string }) => r.logicalResourceId); + expect(logicalIds).toContain('NewGatewayLogicalId'); + expect(logicalIds).toContain('NewTargetLogicalId'); + expect(logicalIds).not.toContain('OldGatewayLogicalId'); + expect(logicalIds).not.toContain('OldTargetLogicalId'); + }); + + it('first-ever import (empty deployed template) still resolves resources', async () => { + await handleImportGateway({ arn: GATEWAY_ARN }); + + const pipelineInput = mockExecuteCdkImportPipeline.mock.calls[0]![0]; + const build = pipelineInput.buildResourcesToImport; + + const deployedTemplate = { Resources: {} }; + const synthTemplate = { + Resources: { + GatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + TargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + }, + }; + + const resources = build(synthTemplate, deployedTemplate); + const logicalIds = resources.map((r: { logicalResourceId: string }) => r.logicalResourceId); + expect(logicalIds).toEqual(['GatewayLogicalId', 'TargetLogicalId']); + }); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-gateway-spec.test.ts b/src/cli/commands/import/__tests__/import-gateway-spec.test.ts new file mode 100644 index 000000000..7c2963edf --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-spec.test.ts @@ -0,0 +1,311 @@ +/** + * toGatewaySpec Unit Tests + * + * Covers gateway-level field mapping from AWS GetGateway response + * to CLI AgentCoreGateway schema: + * - Authorizer type mapping (NONE, AWS_IAM, CUSTOM_JWT with claims, empty arrays) + * - Semantic search configuration + * - Exception level mapping + * - Policy engine configuration + * - Description, tags, resourceName, executionRoleArn + */ +import type { AgentCoreGatewayTarget } from '../../../../schema'; +import type { GatewayDetail } from '../../../aws/agentcore-control'; +import { toGatewaySpec } from '../import-gateway'; +import { describe, expect, it } from 'vitest'; + +/** Helper to build a minimal GatewayDetail for tests. */ +function makeGateway(overrides: Partial = {}): GatewayDetail { + return { + gatewayId: 'gw-test-001', + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gw-test-001', + name: 'TestGateway', + status: 'READY', + authorizerType: 'NONE', + ...overrides, + }; +} + +const emptyTargets: AgentCoreGatewayTarget[] = []; + +// ============================================================================ +// Authorizer Type Mapping +// ============================================================================ + +describe('toGatewaySpec – authorizer type mapping', () => { + it('NONE authorizerType: no authorizerConfiguration in output', () => { + const gw = makeGateway({ authorizerType: 'NONE' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('NONE'); + expect(result).not.toHaveProperty('authorizerConfiguration'); + }); + + it('AWS_IAM authorizerType: maps to AWS_IAM, no authorizerConfiguration', () => { + const gw = makeGateway({ authorizerType: 'AWS_IAM' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('AWS_IAM'); + expect(result).not.toHaveProperty('authorizerConfiguration'); + }); + + it('CUSTOM_JWT basic: maps discoveryUrl, allowedAudience, allowedClients, allowedScopes', () => { + const gw = makeGateway({ + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud1', 'aud2'], + allowedClients: ['client1'], + allowedScopes: ['read', 'write'], + }, + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('CUSTOM_JWT'); + expect(result.authorizerConfiguration).toBeDefined(); + const jwt = result.authorizerConfiguration!.customJwtAuthorizer!; + expect(jwt.discoveryUrl).toBe('https://example.com/.well-known/openid-configuration'); + expect(jwt.allowedAudience).toEqual(['aud1', 'aud2']); + expect(jwt.allowedClients).toEqual(['client1']); + expect(jwt.allowedScopes).toEqual(['read', 'write']); + }); + + it('CUSTOM_JWT with customClaims: maps full claim structure', () => { + const gw = makeGateway({ + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud1'], + customClaims: [ + { + inboundTokenClaimName: 'department', + inboundTokenClaimValueType: 'STRING', + authorizingClaimMatchValue: { + claimMatchOperator: 'EQUALS', + claimMatchValue: { matchValueString: 'engineering' }, + }, + }, + { + inboundTokenClaimName: 'roles', + inboundTokenClaimValueType: 'STRING_ARRAY', + authorizingClaimMatchValue: { + claimMatchOperator: 'CONTAINS_ANY', + claimMatchValue: { matchValueStringList: ['admin', 'editor'] }, + }, + }, + ], + }, + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + const claims = result.authorizerConfiguration!.customJwtAuthorizer!.customClaims!; + expect(claims).toHaveLength(2); + + expect(claims[0]!.inboundTokenClaimName).toBe('department'); + expect(claims[0]!.inboundTokenClaimValueType).toBe('STRING'); + expect(claims[0]!.authorizingClaimMatchValue.claimMatchOperator).toBe('EQUALS'); + expect(claims[0]!.authorizingClaimMatchValue.claimMatchValue.matchValueString).toBe('engineering'); + expect(claims[0]!.authorizingClaimMatchValue.claimMatchValue).not.toHaveProperty('matchValueStringList'); + + expect(claims[1]!.inboundTokenClaimName).toBe('roles'); + expect(claims[1]!.inboundTokenClaimValueType).toBe('STRING_ARRAY'); + expect(claims[1]!.authorizingClaimMatchValue.claimMatchOperator).toBe('CONTAINS_ANY'); + expect(claims[1]!.authorizingClaimMatchValue.claimMatchValue.matchValueStringList).toEqual(['admin', 'editor']); + expect(claims[1]!.authorizingClaimMatchValue.claimMatchValue).not.toHaveProperty('matchValueString'); + }); + + it('CUSTOM_JWT with empty arrays: allowedAudience=[], allowedClients=[] are omitted', () => { + const gw = makeGateway({ + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: [], + allowedClients: [], + allowedScopes: ['openid'], + }, + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + const jwt = result.authorizerConfiguration!.customJwtAuthorizer!; + expect(jwt).not.toHaveProperty('allowedAudience'); + expect(jwt).not.toHaveProperty('allowedClients'); + expect(jwt.allowedScopes).toEqual(['openid']); + }); + + it('missing authorizerType: defaults to NONE', () => { + const gw = makeGateway(); + // Simulate undefined authorizerType by deleting after construction + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (gw as any).authorizerType; + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('NONE'); + expect(result).not.toHaveProperty('authorizerConfiguration'); + }); +}); + +// ============================================================================ +// Semantic Search +// ============================================================================ + +describe('toGatewaySpec – semantic search', () => { + it('searchType=SEMANTIC: enableSemanticSearch is true', () => { + const gw = makeGateway({ + protocolConfiguration: { mcp: { searchType: 'SEMANTIC' } }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.enableSemanticSearch).toBe(true); + }); + + it('searchType=KEYWORD: enableSemanticSearch is false', () => { + const gw = makeGateway({ + protocolConfiguration: { mcp: { searchType: 'KEYWORD' } }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.enableSemanticSearch).toBe(false); + }); + + it('protocolConfiguration missing: enableSemanticSearch is false', () => { + const gw = makeGateway(); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.enableSemanticSearch).toBe(false); + }); +}); + +// ============================================================================ +// Exception Level +// ============================================================================ + +describe('toGatewaySpec – exception level', () => { + it('exceptionLevel=DEBUG: maps to DEBUG', () => { + const gw = makeGateway({ exceptionLevel: 'DEBUG' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.exceptionLevel).toBe('DEBUG'); + }); + + it('exceptionLevel undefined: maps to NONE', () => { + const gw = makeGateway({ exceptionLevel: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.exceptionLevel).toBe('NONE'); + }); + + it('exceptionLevel other value: maps to NONE', () => { + const gw = makeGateway({ exceptionLevel: 'VERBOSE' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.exceptionLevel).toBe('NONE'); + }); +}); + +// ============================================================================ +// Policy Engine +// ============================================================================ + +describe('toGatewaySpec – policy engine', () => { + it('policyEngineConfiguration present: extracts name from ARN last segment, preserves mode', () => { + const gw = makeGateway({ + policyEngineConfiguration: { + arn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/my_policy_engine', + mode: 'ENFORCE', + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.policyEngineConfiguration).toBeDefined(); + expect(result.policyEngineConfiguration!.policyEngineName).toBe('my_policy_engine'); + expect(result.policyEngineConfiguration!.mode).toBe('ENFORCE'); + }); + + it('policyEngineConfiguration absent: field omitted', () => { + const gw = makeGateway(); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('policyEngineConfiguration'); + }); +}); + +// ============================================================================ +// Other Fields +// ============================================================================ + +describe('toGatewaySpec – other fields', () => { + it('resourceName is always set to gateway.name', () => { + const gw = makeGateway({ name: 'AwsGatewayName' }); + const result = toGatewaySpec(gw, emptyTargets, 'local_name'); + + expect(result.resourceName).toBe('AwsGatewayName'); + expect(result.name).toBe('local_name'); + }); + + it('description present: included in output', () => { + const gw = makeGateway({ description: 'My gateway description' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.description).toBe('My gateway description'); + }); + + it('description undefined: omitted from output', () => { + const gw = makeGateway({ description: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('description'); + }); + + it('tags present with entries: included in output', () => { + const gw = makeGateway({ tags: { env: 'prod', team: 'platform' } }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.tags).toEqual({ env: 'prod', team: 'platform' }); + }); + + it('tags empty object: omitted from output', () => { + const gw = makeGateway({ tags: {} }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('tags'); + }); + + it('tags undefined: omitted from output', () => { + const gw = makeGateway({ tags: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('tags'); + }); + + it('executionRoleArn: mapped from gateway.roleArn', () => { + const gw = makeGateway({ roleArn: 'arn:aws:iam::123456789012:role/GatewayRole' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.executionRoleArn).toBe('arn:aws:iam::123456789012:role/GatewayRole'); + }); + + it('roleArn undefined: executionRoleArn omitted from output', () => { + const gw = makeGateway({ roleArn: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('executionRoleArn'); + }); + + it('targets are passed through to output', () => { + const targets: AgentCoreGatewayTarget[] = [ + { name: 'target1', targetType: 'mcpServer', endpoint: 'https://mcp.example.com' }, + ]; + const gw = makeGateway(); + const result = toGatewaySpec(gw, targets, 'my_gw'); + + expect(result.targets).toBe(targets); + expect(result.targets).toHaveLength(1); + expect(result.targets[0]!.name).toBe('target1'); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-gateway-targets.test.ts b/src/cli/commands/import/__tests__/import-gateway-targets.test.ts new file mode 100644 index 000000000..3624ce545 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-targets.test.ts @@ -0,0 +1,355 @@ +/** + * Import Gateway Target Mapping Unit Tests + * + * Covers toGatewayTargetSpec for non-mcpServer target types: + * - apiGateway: toolFilters, toolOverrides, outboundAuth + * - openApiSchema: S3 URI mapping, missing URI warning + * - smithyModel: S3 URI mapping, missing URI warning + * - lambda: lambdaFunctionArn mapping, missing ARN, inline-only schema + * - Unrecognized target type + */ +import type { GatewayTargetDetail } from '../../../aws/agentcore-control'; +import { toGatewayTargetSpec } from '../import-gateway'; +import { describe, expect, it, vi } from 'vitest'; + +/** Helper to build a minimal GatewayTargetDetail with only the fields under test. */ +function baseDetail(overrides: Partial = {}): GatewayTargetDetail { + return { + targetId: 'tgt-001', + name: 'test_target', + status: 'READY', + ...overrides, + }; +} + +// ============================================================================ +// apiGateway target +// ============================================================================ + +describe('toGatewayTargetSpec — apiGateway', () => { + it('maps restApiId, stage, and toolFilters correctly', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [ + { filterPath: '/pets', methods: ['GET', 'POST'] }, + { filterPath: '/users', methods: ['GET'] }, + ], + }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('apiGateway'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const apigw = (result as any).apiGateway; + expect(apigw.restApiId).toBe('abc123'); + expect(apigw.stage).toBe('prod'); + expect(apigw.apiGatewayToolConfiguration.toolFilters).toEqual([ + { filterPath: '/pets', methods: ['GET', 'POST'] }, + { filterPath: '/users', methods: ['GET'] }, + ]); + }); + + it('maps toolOverrides when present', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [], + toolOverrides: [ + { name: 'listPets', path: '/pets', method: 'GET', description: 'List all pets' }, + { name: 'createPet', path: '/pets', method: 'POST' }, + ], + }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const apigw = (result as any).apiGateway; + expect(apigw.apiGatewayToolConfiguration.toolOverrides).toEqual([ + { name: 'listPets', path: '/pets', method: 'GET', description: 'List all pets' }, + { name: 'createPet', path: '/pets', method: 'POST' }, + ]); + }); + + it('omits toolOverrides when not present', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [{ filterPath: '/pets', methods: ['GET'] }], + }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const apigw = (result as any).apiGateway; + expect(apigw.apiGatewayToolConfiguration.toolOverrides).toBeUndefined(); + }); + + it('returns outboundAuth when OAuth credential is configured', () => { + const providerArn = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:credential-provider/cred-001'; + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { toolFilters: [] }, + }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['read', 'write'], + }, + }, + }, + ], + }); + + const credentials = new Map([[providerArn, 'my_oauth_cred']]); + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toBeDefined(); + expect(result!.outboundAuth).toEqual({ + type: 'OAUTH', + credentialName: 'my_oauth_cred', + scopes: ['read', 'write'], + }); + }); +}); + +// ============================================================================ +// openApiSchema target +// ============================================================================ + +describe('toGatewayTargetSpec — openApiSchema', () => { + it('maps S3 URI and bucketOwnerAccountId correctly', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + openApiSchema: { + s3: { uri: 's3://my-bucket/schema.yaml', bucketOwnerAccountId: '123456789012' }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('openApiSchema'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schemaSource = (result as any).schemaSource; + expect(schemaSource.s3.uri).toBe('s3://my-bucket/schema.yaml'); + expect(schemaSource.s3.bucketOwnerAccountId).toBe('123456789012'); + }); + + it('returns undefined and emits warning when S3 URI is missing', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + openApiSchema: { inlinePayload: '{"openapi":"3.0.0"}' }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(openApiSchema) has no S3 URI, skipping')); + }); +}); + +// ============================================================================ +// smithyModel target +// ============================================================================ + +describe('toGatewayTargetSpec — smithyModel', () => { + it('maps S3 URI correctly', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + smithyModel: { + s3: { uri: 's3://models-bucket/model.json' }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('smithyModel'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schemaSource = (result as any).schemaSource; + expect(schemaSource.s3.uri).toBe('s3://models-bucket/model.json'); + expect(schemaSource.s3.bucketOwnerAccountId).toBeUndefined(); + }); + + it('returns undefined and emits warning when S3 URI is missing', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + smithyModel: { inlinePayload: '{"smithy":"1.0"}' }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(smithyModel) has no S3 URI, skipping')); + }); +}); + +// ============================================================================ +// lambda target +// ============================================================================ + +describe('toGatewayTargetSpec — lambda', () => { + it('maps lambda with S3 tool schema to lambdaFunctionArn type', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', + toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('lambdaFunctionArn'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const lambdaConfig = (result as any).lambdaFunctionArn; + expect(lambdaConfig.lambdaArn).toBe('arn:aws:lambda:us-west-2:123456789012:function:my-func'); + expect(lambdaConfig.toolSchemaFile).toBe('s3://schemas/tools.json'); + }); + + it('returns undefined and emits warning when lambdaArn is missing', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: '', + toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(lambda) has no ARN, skipping')); + }); + + it('returns undefined and emits warning when lambda has inline schema only', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', + toolSchema: { inlinePayload: '{"tools":[]}' }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith( + expect.stringContaining('has inline tool schema, which cannot be imported') + ); + }); + + it('emits progress message for successful lambda mapping', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', + toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('Mapping compute-backed Lambda target')); + }); +}); + +// ============================================================================ +// Unrecognized target type +// ============================================================================ + +describe('toGatewayTargetSpec — unrecognized target type', () => { + it('returns undefined and emits warning when no known mcp type matches', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: {}, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('unrecognized target type')); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-gateway.test.ts b/src/cli/commands/import/__tests__/import-gateway.test.ts new file mode 100644 index 000000000..2cd0b2099 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway.test.ts @@ -0,0 +1,250 @@ +/** + * Tests for toGatewayTargetSpec() — mcpServer target mapping and credential resolution. + */ +import type { GatewayTargetDetail } from '../../../aws/agentcore-control'; +import { + _resolveOutboundAuth as resolveOutboundAuth, + _toGatewayTargetSpec as toGatewayTargetSpec, +} from '../import-gateway'; +import { describe, expect, it, vi } from 'vitest'; + +// ============================================================================ +// Helpers +// ============================================================================ + +function makeDetail(overrides: Partial = {}): GatewayTargetDetail { + return { + targetId: 'tgt-001', + name: 'my-mcp-target', + status: 'READY', + ...overrides, + }; +} + +// ============================================================================ +// toGatewayTargetSpec — mcpServer mapping +// ============================================================================ + +describe('toGatewayTargetSpec — mcpServer targets', () => { + it('maps mcpServer with no auth', () => { + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + }); + const credentials = new Map(); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + }); + expect(result).not.toHaveProperty('outboundAuth'); + expect(onProgress).not.toHaveBeenCalled(); + }); + + it('maps mcpServer with OAuth credential (resolved)', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/my-oauth'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['read', 'write'], + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'my-oauth-cred']]); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + outboundAuth: { + type: 'OAUTH', + credentialName: 'my-oauth-cred', + scopes: ['read', 'write'], + }, + }); + }); + + it('maps mcpServer with API_KEY credential (resolved)', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/my-apikey'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'API_KEY', + credentialProvider: { + apiKeyCredentialProvider: { + providerArn, + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'my-api-key-cred']]); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + outboundAuth: { + type: 'API_KEY', + credentialName: 'my-api-key-cred', + }, + }); + }); + + it('throws when OAuth credential not found in project', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/missing-oauth'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['read'], + }, + }, + }, + ], + }); + const credentials = new Map(); + const onProgress = vi.fn(); + + expect(() => toGatewayTargetSpec(detail, credentials, onProgress)).toThrow( + 'uses an OAuth credential provider not found' + ); + }); + + it('throws when API_KEY credential not found in project', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/missing-apikey'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'API_KEY', + credentialProvider: { + apiKeyCredentialProvider: { + providerArn, + }, + }, + }, + ], + }); + const credentials = new Map(); + const onProgress = vi.fn(); + + expect(() => toGatewayTargetSpec(detail, credentials, onProgress)).toThrow( + 'uses an API Key credential provider not found' + ); + }); + + it('returns undefined and warns when target has no MCP configuration', () => { + const detail = makeDetail({ + targetConfiguration: undefined, + }); + const credentials = new Map(); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('no MCP configuration')); + }); +}); + +// ============================================================================ +// resolveOutboundAuth — OAuth scopes handling +// ============================================================================ + +describe('resolveOutboundAuth — scopes handling', () => { + it('includes scopes when OAuth provider has non-empty scopes array', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/oauth-scoped'; + const detail = makeDetail({ + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['openid', 'profile', 'email'], + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'scoped-cred']]); + const onProgress = vi.fn(); + + const result = resolveOutboundAuth(detail, credentials, onProgress); + + expect(result).toEqual({ + type: 'OAUTH', + credentialName: 'scoped-cred', + scopes: ['openid', 'profile', 'email'], + }); + }); + + it('omits scopes when OAuth provider has empty scopes array', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/oauth-no-scope'; + const detail = makeDetail({ + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: [], + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'no-scope-cred']]); + const onProgress = vi.fn(); + + const result = resolveOutboundAuth(detail, credentials, onProgress); + + expect(result).toEqual({ + type: 'OAUTH', + credentialName: 'no-scope-cred', + }); + expect(result).not.toHaveProperty('scopes'); + }); +}); diff --git a/src/cli/commands/import/actions.ts b/src/cli/commands/import/actions.ts index a57685b72..71eb70f83 100644 --- a/src/cli/commands/import/actions.ts +++ b/src/cli/commands/import/actions.ts @@ -543,11 +543,14 @@ export async function handleImport(options: ImportOptions): Promise { + buildResourcesToImport: (synthTemplate, deployedTemplate) => { const resourcesToImport: ResourceToImport[] = []; + const deployedIds = new Set(Object.keys(deployedTemplate.Resources)); for (const agent of agentsToImport) { - const runtimeLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Runtime'); + const runtimeLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Runtime').filter( + id => !deployedIds.has(id) + ); let logicalId: string | undefined; const expectedRuntimeName = `${projectName}_${agent.name}`; @@ -555,7 +558,8 @@ export async function handleImport(options: ImportOptions): Promise !deployedIds.has(id) + ); let logicalId: string | undefined; - logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', memory.name); + logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', memory.name, { + excludeLogicalIds: deployedIds, + }); // CDK prefixes memory names with the project name (e.g. "myproject_Agent_mem"), // so also try matching with the project name prefix. if (!logicalId) { const prefixedName = `${projectName}_${memory.name}`; - logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', prefixedName); + logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', prefixedName, { + excludeLogicalIds: deployedIds, + }); } if (!logicalId && memoryLogicalIds.length === 1) { diff --git a/src/cli/commands/import/command.ts b/src/cli/commands/import/command.ts index 01ada8915..381167aaa 100644 --- a/src/cli/commands/import/command.ts +++ b/src/cli/commands/import/command.ts @@ -1,6 +1,7 @@ import { handleImport } from './actions'; import { ANSI } from './constants'; import { registerImportEvaluator } from './import-evaluator'; +import { registerImportGateway } from './import-gateway'; import { registerImportMemory } from './import-memory'; import { registerImportOnlineEval } from './import-online-eval'; import { registerImportRuntime } from './import-runtime'; @@ -152,4 +153,5 @@ export const registerImport = (program: Command) => { registerImportMemory(importCmd); registerImportEvaluator(importCmd); registerImportOnlineEval(importCmd); + registerImportGateway(importCmd); }; diff --git a/src/cli/commands/import/constants.ts b/src/cli/commands/import/constants.ts index 9de391f55..25755dc26 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -18,6 +18,7 @@ export const CFN_RESOURCE_IDENTIFIERS: Record = { 'AWS::BedrockAgentCore::Runtime': ['AgentRuntimeId'], 'AWS::BedrockAgentCore::Memory': ['MemoryId'], 'AWS::BedrockAgentCore::Gateway': ['GatewayIdentifier'], + 'AWS::BedrockAgentCore::GatewayTarget': ['GatewayIdentifier', 'TargetId'], 'AWS::BedrockAgentCore::Evaluator': ['EvaluatorId'], 'AWS::BedrockAgentCore::OnlineEvaluationConfig': ['OnlineEvaluationConfigId'], }; diff --git a/src/cli/commands/import/import-evaluator.ts b/src/cli/commands/import/import-evaluator.ts index be85829f3..7c6c8b0d2 100644 --- a/src/cli/commands/import/import-evaluator.ts +++ b/src/cli/commands/import/import-evaluator.ts @@ -10,6 +10,7 @@ import { ANSI } from './constants'; import { failResult, parseAndValidateArn } from './import-utils'; import { executeResourceImport } from './resource-import'; import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; +import { ResourceNotFoundException } from '@aws-sdk/client-bedrock-agentcore-control'; import type { Command } from '@commander-js/extra-typings'; /** @@ -92,11 +93,18 @@ const evaluatorDescriptor: ResourceImportDescriptor 0) { - const oecDetails = await Promise.all( - oecSummaries.map(s => - getOnlineEvaluationConfig({ region: target.region, configId: s.onlineEvaluationConfigId }) + // Configs can be deleted between list and get (TOCTOU race). + // Skip ResourceNotFoundException — a deleted config can't be locking our evaluator. + const oecDetails = ( + await Promise.all( + oecSummaries.map(s => + getOnlineEvaluationConfig({ region: target.region, configId: s.onlineEvaluationConfigId }).catch(err => { + if (err instanceof ResourceNotFoundException) return null; + throw err; + }) + ) ) - ); + ).filter(r => r !== null); const referencingOec = oecDetails.find(oec => oec.evaluatorIds?.includes(detail.evaluatorId)); diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts new file mode 100644 index 000000000..3c2384e03 --- /dev/null +++ b/src/cli/commands/import/import-gateway.ts @@ -0,0 +1,690 @@ +import type { + AgentCoreGateway, + AgentCoreGatewayTarget, + AgentCoreProjectSpec, + AuthorizerConfig, + CustomClaimValidation, + GatewayAuthorizerType, + GatewayExceptionLevel, + GatewayPolicyEngineConfiguration, + OutboundAuth, +} from '../../../schema'; +import { GatewayNameSchema } from '../../../schema'; +import type { GatewayDetail, GatewayTargetDetail } from '../../aws/agentcore-control'; +import { + getGatewayDetail, + getGatewayTargetDetail, + listAllGatewayTargets, + listAllGateways, +} from '../../aws/agentcore-control'; +import { isAccessDeniedError } from '../../errors'; +import { ANSI } from './constants'; +import { executeCdkImportPipeline } from './import-pipeline'; +import { + failResult, + findResourceInDeployedState, + parseAndValidateArn, + resolveImportContext, + toStackName, +} from './import-utils'; +import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; +import type { ImportResourceOptions, ImportResourceResult, ResourceToImport } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +// ============================================================================ +// AWS → CLI Schema Mapping +// ============================================================================ + +/** + * Map GetGatewayTarget response to CLI AgentCoreGatewayTarget schema. + * Determines target type from the targetConfiguration.mcp union. + */ +function toGatewayTargetSpec( + detail: GatewayTargetDetail, + credentials: Map, + onProgress: (msg: string) => void +): AgentCoreGatewayTarget | undefined { + const mcp = detail.targetConfiguration?.mcp; + if (!mcp) { + onProgress(`Warning: Target "${detail.name}" has no MCP configuration, skipping`); + return undefined; + } + + const outboundAuth = resolveOutboundAuth(detail, credentials, onProgress); + + // MCP Server (external endpoint) + if (mcp.mcpServer) { + return { + name: detail.name, + targetType: 'mcpServer', + endpoint: mcp.mcpServer.endpoint, + ...(outboundAuth && { outboundAuth }), + }; + } + + // API Gateway + if (mcp.apiGateway) { + const apigw = mcp.apiGateway; + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ + const target: AgentCoreGatewayTarget = { + name: detail.name, + targetType: 'apiGateway', + apiGateway: { + restApiId: apigw.restApiId, + stage: apigw.stage, + apiGatewayToolConfiguration: { + toolFilters: (apigw.apiGatewayToolConfiguration?.toolFilters ?? []).map(f => ({ + filterPath: f.filterPath, + methods: f.methods, + })) as any, + ...(apigw.apiGatewayToolConfiguration?.toolOverrides && { + toolOverrides: apigw.apiGatewayToolConfiguration.toolOverrides.map(o => ({ + name: o.name, + path: o.path, + method: o.method, + ...(o.description && { description: o.description }), + })), + }), + }, + } as any, + ...(outboundAuth && { outboundAuth }), + }; + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ + return target; + } + + // OpenAPI Schema + if (mcp.openApiSchema) { + const schema = mcp.openApiSchema; + if (schema.s3?.uri) { + return { + name: detail.name, + targetType: 'openApiSchema', + schemaSource: { + s3: { + uri: schema.s3.uri, + ...(schema.s3.bucketOwnerAccountId && { bucketOwnerAccountId: schema.s3.bucketOwnerAccountId }), + }, + }, + ...(outboundAuth && { outboundAuth }), + }; + } + onProgress(`Warning: Target "${detail.name}" (openApiSchema) has no S3 URI, skipping`); + return undefined; + } + + // Smithy Model + if (mcp.smithyModel) { + const schema = mcp.smithyModel; + if (schema.s3?.uri) { + return { + name: detail.name, + targetType: 'smithyModel', + schemaSource: { + s3: { + uri: schema.s3.uri, + ...(schema.s3.bucketOwnerAccountId && { bucketOwnerAccountId: schema.s3.bucketOwnerAccountId }), + }, + }, + ...(outboundAuth && { outboundAuth }), + }; + } + onProgress(`Warning: Target "${detail.name}" (smithyModel) has no S3 URI, skipping`); + return undefined; + } + + // Lambda (compute-backed) → map to lambdaFunctionArn + if (mcp.lambda) { + const lambdaArn = mcp.lambda.lambdaArn; + if (!lambdaArn) { + onProgress(`Warning: Target "${detail.name}" (lambda) has no ARN, skipping`); + return undefined; + } + + // Extract tool schema S3 URI if available + /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ + const toolSchema = mcp.lambda.toolSchema; + const s3Uri: string | undefined = toolSchema?.s3?.uri; + /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ + + if (s3Uri) { + onProgress(`Mapping compute-backed Lambda target "${detail.name}" to lambdaFunctionArn type`); + return { + name: detail.name, + targetType: 'lambdaFunctionArn', + lambdaFunctionArn: { + lambdaArn, + toolSchemaFile: s3Uri, + }, + ...(outboundAuth && { outboundAuth }), + }; + } + + // Lambda without S3 schema — can't import as lambdaFunctionArn since toolSchemaFile is required + onProgress(`Warning: Target "${detail.name}" (lambda) has inline tool schema, which cannot be imported. Skipping.`); + return undefined; + } + + onProgress(`Warning: Target "${detail.name}" has an unrecognized target type, skipping`); + return undefined; +} + +/** + * Resolve outbound auth from credential provider configurations. + */ +function resolveOutboundAuth( + detail: GatewayTargetDetail, + credentials: Map, + _onProgress: (msg: string) => void +): OutboundAuth | undefined { + const configs = detail.credentialProviderConfigurations; + if (!configs || configs.length === 0) return undefined; + + for (const config of configs) { + if (config.credentialProviderType === 'OAUTH' && config.credentialProvider?.oauthCredentialProvider) { + const providerArn = config.credentialProvider.oauthCredentialProvider.providerArn; + const credentialName = credentials.get(providerArn); + if (credentialName) { + return { + type: 'OAUTH', + credentialName, + ...(config.credentialProvider.oauthCredentialProvider.scopes?.length && { + scopes: config.credentialProvider.oauthCredentialProvider.scopes, + }), + }; + } + throw new Error( + `Target "${detail.name}" uses an OAuth credential provider not found in this project's deployed state. ` + + 'Import the credential first with `agentcore add credential` and re-run.' + ); + } + + if (config.credentialProviderType === 'API_KEY' && config.credentialProvider?.apiKeyCredentialProvider) { + const providerArn = config.credentialProvider.apiKeyCredentialProvider.providerArn; + const credentialName = credentials.get(providerArn); + if (credentialName) { + return { type: 'API_KEY', credentialName }; + } + throw new Error( + `Target "${detail.name}" uses an API Key credential provider not found in this project's deployed state. ` + + 'Import the credential first with `agentcore add credential` and re-run.' + ); + } + + // GATEWAY_IAM_ROLE — no outbound auth needed + } + + return undefined; +} + +/** + * Map GetGateway + GetGatewayTarget[] responses to CLI AgentCoreGateway schema. + * @internal + */ +export function toGatewaySpec( + gateway: GatewayDetail, + targets: AgentCoreGatewayTarget[], + localName: string +): AgentCoreGateway { + const authorizerType = (gateway.authorizerType ?? 'NONE') as GatewayAuthorizerType; + + let authorizerConfiguration: AuthorizerConfig | undefined; + if (authorizerType === 'CUSTOM_JWT' && gateway.authorizerConfiguration?.customJwtAuthorizer) { + const jwt = gateway.authorizerConfiguration.customJwtAuthorizer; + authorizerConfiguration = { + customJwtAuthorizer: { + discoveryUrl: jwt.discoveryUrl, + ...(jwt.allowedAudience?.length && { allowedAudience: jwt.allowedAudience }), + ...(jwt.allowedClients?.length && { allowedClients: jwt.allowedClients }), + ...(jwt.allowedScopes?.length && { allowedScopes: jwt.allowedScopes }), + ...(jwt.customClaims?.length && { + customClaims: jwt.customClaims.map( + (c): CustomClaimValidation => ({ + inboundTokenClaimName: c.inboundTokenClaimName, + inboundTokenClaimValueType: c.inboundTokenClaimValueType as 'STRING' | 'STRING_ARRAY', + authorizingClaimMatchValue: { + claimMatchOperator: c.authorizingClaimMatchValue.claimMatchOperator as + | 'EQUALS' + | 'CONTAINS' + | 'CONTAINS_ANY', + claimMatchValue: { + ...(c.authorizingClaimMatchValue.claimMatchValue.matchValueString && { + matchValueString: c.authorizingClaimMatchValue.claimMatchValue.matchValueString, + }), + ...(c.authorizingClaimMatchValue.claimMatchValue.matchValueStringList && { + matchValueStringList: c.authorizingClaimMatchValue.claimMatchValue.matchValueStringList, + }), + }, + }, + }) + ), + }), + }, + }; + } + + const enableSemanticSearch = gateway.protocolConfiguration?.mcp?.searchType === 'SEMANTIC'; + const exceptionLevel: GatewayExceptionLevel = gateway.exceptionLevel === 'DEBUG' ? 'DEBUG' : 'NONE'; + + let policyEngineConfiguration: GatewayPolicyEngineConfiguration | undefined; + if (gateway.policyEngineConfiguration) { + // Extract policy engine name from ARN (last segment after /) + const arnParts = gateway.policyEngineConfiguration.arn.split('/'); + const policyEngineName = arnParts[arnParts.length - 1] ?? gateway.policyEngineConfiguration.arn; + policyEngineConfiguration = { + policyEngineName, + mode: gateway.policyEngineConfiguration.mode as 'LOG_ONLY' | 'ENFORCE', + }; + } + + return { + name: localName, + resourceName: gateway.name, + ...(gateway.description && { description: gateway.description }), + targets, + authorizerType, + ...(authorizerConfiguration && { authorizerConfiguration }), + enableSemanticSearch, + exceptionLevel, + ...(policyEngineConfiguration && { policyEngineConfiguration }), + ...(gateway.roleArn && { executionRoleArn: gateway.roleArn }), + ...(gateway.tags && Object.keys(gateway.tags).length > 0 && { tags: gateway.tags }), + }; +} + +// ============================================================================ +// Credential ARN → Name Resolution +// ============================================================================ + +/** + * Build a map from credential provider ARN → credential name + * using the project's deployed state. + * @internal + */ +export async function buildCredentialArnMap( + configIO: { readDeployedState: () => Promise }, + targetName: string +): Promise> { + const map = new Map(); + try { + /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ + const state = (await configIO.readDeployedState()) as any; + const credentials = state?.targets?.[targetName]?.resources?.credentials; + if (credentials && typeof credentials === 'object') { + for (const [name, entry] of Object.entries(credentials)) { + const arn = (entry as any)?.credentialProviderArn; + if (typeof arn === 'string') { + map.set(arn, name); + } + } + } + /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ + } catch { + // No deployed state — credentials won't be resolved + } + return map; +} + +// ============================================================================ +// Import Flow +// ============================================================================ + +/** + * Handle `agentcore import gateway`. + */ +export async function handleImportGateway(options: ImportResourceOptions): Promise { + let configSnapshot: AgentCoreProjectSpec | undefined; + let configWritten = false; + let importCtx: Awaited> | undefined; + + const rollback = async () => { + if (configWritten && configSnapshot && importCtx) { + try { + await importCtx.ctx.configIO.writeProjectSpec(configSnapshot); + } catch (err) { + console.warn(`Warning: Could not restore agentcore.json: ${err instanceof Error ? err.message : String(err)}`); + } + } + }; + + try { + // 1-2. Validate project context and resolve target + importCtx = await resolveImportContext(options, 'import-gateway'); + const { ctx, target, logger, onProgress } = importCtx; + + // 3. Fetch gateway from AWS + logger.startStep('Fetch gateway from AWS'); + let gatewayId: string; + + if (options.arn) { + gatewayId = parseAndValidateArn(options.arn, 'gateway', target).resourceId; + } else { + onProgress('Listing gateways in your account...'); + const summaries = await listAllGateways({ region: target.region }); + + if (summaries.length === 0) { + return failResult(logger, 'No gateways found in your account.', 'gateway', ''); + } + + if (summaries.length === 1) { + gatewayId = summaries[0]!.gatewayId; + onProgress(`Found 1 gateway: ${summaries[0]!.name} (${gatewayId}). Auto-selecting.`); + } else { + console.log(`\nFound ${summaries.length} gateway(s):\n`); + for (let i = 0; i < summaries.length; i++) { + const s = summaries[i]!; + console.log( + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.name} — ${s.status}\n` + + ` ${ANSI.dim}${s.gatewayId} (${s.authorizerType})${ANSI.reset}` + ); + } + console.log(''); + return failResult( + logger, + 'Multiple gateways found. Use --arn to specify which gateway to import.', + 'gateway', + '' + ); + } + } + + onProgress(`Fetching gateway details for ${gatewayId}...`); + let gatewayDetail; + try { + gatewayDetail = await getGatewayDetail({ region: target.region, gatewayId }); + } catch (err) { + if (isAccessDeniedError(err)) { + return failResult( + logger, + `Gateway "${gatewayId}" could not be found in region ${target.region}. ` + + `AWS returned AccessDenied, which for this service typically means the gateway does not exist, ` + + `the ARN is malformed, or your credentials lack bedrock-agentcore:GetGateway permission. ` + + `Verify the ARN with: aws bedrock-agentcore-control list-gateways --region ${target.region}`, + 'gateway', + options.name ?? '' + ); + } + throw err; + } + + if (gatewayDetail.status !== 'READY') { + onProgress(`Warning: Gateway status is ${gatewayDetail.status}, not READY`); + } + + // 3b. Fetch all targets + onProgress('Listing gateway targets...'); + const targetSummaries = await listAllGatewayTargets({ region: target.region, gatewayId }); + onProgress(`Found ${targetSummaries.length} target(s) for gateway`); + + const targetDetails: GatewayTargetDetail[] = []; + for (const ts of targetSummaries) { + const td = await getGatewayTargetDetail({ region: target.region, gatewayId, targetId: ts.targetId }); + targetDetails.push(td); + } + logger.endStep('success'); + + // 4. Validate name + logger.startStep('Validate name'); + let localName = options.name ?? gatewayDetail.name; + const nameResult = GatewayNameSchema.safeParse(localName); + if (!nameResult.success) { + return failResult( + logger, + `Invalid name "${localName}". ${nameResult.error.issues[0]?.message ?? 'Invalid gateway name'}`, + 'gateway', + localName + ); + } + onProgress(`Gateway: ${gatewayDetail.name} -> local name: ${localName}`); + logger.endStep('success'); + + // 5. Check for duplicates + logger.startStep('Check for duplicates'); + const projectSpec = await ctx.configIO.readProjectSpec(); + const existingNames = new Set(projectSpec.agentCoreGateways.map(g => g.name)); + if (existingNames.has(localName)) { + return failResult( + logger, + `Gateway "${localName}" already exists in the project. Use --name to specify a different local name.`, + 'gateway', + localName + ); + } + const targetName = target.name ?? 'default'; + const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'gateway', gatewayId); + const isReimport = !!existingResource; + if (existingResource) { + if (!options.name) { + localName = existingResource; + } + onProgress(`Gateway already managed by CloudFormation — re-adding to project config`); + } + logger.endStep('success'); + + // 6. Map AWS responses to CLI schema + logger.startStep('Map gateway to project schema'); + const credentialArnMap = await buildCredentialArnMap(ctx.configIO, targetName); + + const mappedTargets: AgentCoreGatewayTarget[] = []; + for (const td of targetDetails) { + const mapped = toGatewayTargetSpec(td, credentialArnMap, onProgress); + if (mapped) { + mappedTargets.push(mapped); + } + } + + const gatewaySpec = toGatewaySpec(gatewayDetail, mappedTargets, localName); + onProgress(`Mapped gateway with ${mappedTargets.length} target(s)`); + if (mappedTargets.length < targetDetails.length) { + onProgress( + `Warning: ${targetDetails.length - mappedTargets.length} target(s) could not be mapped and were skipped` + ); + } + logger.endStep('success'); + + // 7. Update project config + logger.startStep('Update project config'); + configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; + projectSpec.agentCoreGateways.push(gatewaySpec); + await ctx.configIO.writeProjectSpec(projectSpec); + configWritten = true; + onProgress(`Added gateway "${localName}" to agentcore.json`); + logger.endStep('success'); + + // 8. CDK build -> synth -> bootstrap -> phase 1 -> phase 2 -> update state + logger.startStep('Build and synth CDK'); + const stackName = toStackName(ctx.projectName, targetName); + + // Build target ID map for CFN import: target name → physical target ID + const targetIdMap = new Map(); + for (const td of targetDetails) { + const mappedTarget = mappedTargets.find(mt => mt.name === td.name); + if (mappedTarget) { + targetIdMap.set(td.name, td.targetId); + } + } + + const pipelineResult = await executeCdkImportPipeline({ + projectRoot: ctx.projectRoot, + stackName, + target, + configIO: ctx.configIO, + targetName, + onProgress, + buildResourcesToImport: (synthTemplate, deployedTemplate) => { + const resourcesToImport: ResourceToImport[] = []; + + // Exclude logical IDs already managed by the stack so we never re-import + // a previously-imported gateway or target with a colliding Name. + const deployedIds = new Set(Object.keys(deployedTemplate.Resources)); + + // Find gateway logical ID + const gatewayResourceName = `${ctx.projectName}-${localName}`; + let gatewayLogicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Gateway', + 'Name', + gatewayResourceName, + { excludeLogicalIds: deployedIds } + ); + gatewayLogicalId ??= findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Gateway', + 'Name', + localName, + { excludeLogicalIds: deployedIds } + ); + if (!gatewayLogicalId) { + const candidateGatewayIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Gateway').filter( + id => !deployedIds.has(id) + ); + if (candidateGatewayIds.length === 1) { + gatewayLogicalId = candidateGatewayIds[0]; + } + } + + if (!gatewayLogicalId) { + return []; + } + + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::Gateway', + logicalResourceId: gatewayLogicalId, + resourceIdentifier: { GatewayIdentifier: gatewayId }, + }); + + // Find target logical IDs (excluding those already in the deployed stack) + const candidateTargetIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::GatewayTarget').filter( + id => !deployedIds.has(id) + ); + + for (const [tName, tId] of targetIdMap) { + // Try name-based matching first + let targetLogicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::GatewayTarget', + 'Name', + tName, + { excludeLogicalIds: deployedIds } + ); + + // Fall back: if exactly one unmatched target logical ID remains, use it + if (!targetLogicalId && candidateTargetIds.length === 1 && targetIdMap.size === 1) { + targetLogicalId = candidateTargetIds[0]; + } + + if (targetLogicalId) { + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::GatewayTarget', + logicalResourceId: targetLogicalId, + resourceIdentifier: { GatewayIdentifier: gatewayId, TargetId: tId }, + }); + } else { + onProgress(`Warning: Could not find logical ID for target "${tName}" in CloudFormation template`); + } + } + + return resourcesToImport; + }, + deployedStateEntries: [{ type: 'gateway', name: localName, id: gatewayId, arn: gatewayDetail.gatewayArn }], + }); + + if (pipelineResult.noResources) { + if (isReimport) { + logger.endStep('success'); + logger.finalize(true); + return { + success: true, + resourceType: 'gateway', + resourceName: localName, + resourceId: gatewayId, + logPath: logger.getRelativeLogPath(), + }; + } + const error = `Could not find logical ID for gateway "${localName}" in CloudFormation template`; + await rollback(); + return failResult(logger, error, 'gateway', localName); + } + + if (!pipelineResult.success) { + await rollback(); + logger.endStep('error', pipelineResult.error); + logger.finalize(false); + return { + success: false, + error: pipelineResult.error, + resourceType: 'gateway', + resourceName: localName, + logPath: logger.getRelativeLogPath(), + }; + } + logger.endStep('success'); + + // 9. Return success + logger.finalize(true); + return { + success: true, + resourceType: 'gateway', + resourceName: localName, + resourceId: gatewayId, + logPath: logger.getRelativeLogPath(), + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + await rollback(); + if (importCtx) { + importCtx.logger.log(message, 'error'); + importCtx.logger.finalize(false); + } + return { + success: false, + error: message, + resourceType: 'gateway', + resourceName: options.name ?? '', + logPath: importCtx?.logger.getRelativeLogPath(), + }; + } +} + +/** @internal — exported for unit testing */ +export { + toGatewayTargetSpec as _toGatewayTargetSpec, + toGatewayTargetSpec, + resolveOutboundAuth as _resolveOutboundAuth, +}; + +// ============================================================================ +// Command Registration +// ============================================================================ + +/** + * Register the `import gateway` subcommand. + */ +export function registerImportGateway(importCmd: Command): void { + importCmd + .command('gateway') + .description('Import an existing AgentCore Gateway (with targets) from your AWS account') + .option('--arn ', 'Gateway ARN to import') + .action(async (cliOptions: ImportResourceOptions) => { + const result = await handleImportGateway(cliOptions); + + if (result.success) { + console.log(''); + console.log(`${ANSI.green}Gateway imported successfully!${ANSI.reset}`); + console.log(` Name: ${result.resourceName}`); + console.log(` ID: ${result.resourceId}`); + console.log(''); + console.log(`${ANSI.dim}Next steps:${ANSI.reset}`); + console.log(` agentcore deploy ${ANSI.dim}Deploy the imported stack${ANSI.reset}`); + console.log(` agentcore status ${ANSI.dim}Verify resource status${ANSI.reset}`); + console.log(` agentcore fetch access ${ANSI.dim}Get gateway URL and token${ANSI.reset}`); + console.log(''); + } else { + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); + if (result.logPath) { + console.error(`Log: ${result.logPath}`); + } + process.exit(1); + } + }); +} diff --git a/src/cli/commands/import/import-pipeline.ts b/src/cli/commands/import/import-pipeline.ts index 6f6444f9a..75b9c70dd 100644 --- a/src/cli/commands/import/import-pipeline.ts +++ b/src/cli/commands/import/import-pipeline.ts @@ -21,7 +21,7 @@ export interface CdkImportPipelineInput { onProgress: (message: string) => void; /** Caller builds the import resource list from the synthesized template. */ - buildResourcesToImport: (synthTemplate: CfnTemplate) => ResourceToImport[]; + buildResourcesToImport: (synthTemplate: CfnTemplate, deployedTemplate: CfnTemplate) => ResourceToImport[]; /** Entries to write into deployed-state.json after a successful import. */ deployedStateEntries: ImportedResource[]; @@ -114,7 +114,7 @@ export async function executeCdkImportPipeline(input: CdkImportPipelineInput): P } // 7. Build resources to import (caller-specific logic) - const resourcesToImport = buildResourcesToImport(synthTemplate); + const resourcesToImport = buildResourcesToImport(synthTemplate, deployedTemplate); if (resourcesToImport.length === 0) { return { success: true, noResources: true }; diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index 2b825812c..5829bbc6c 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -130,13 +130,30 @@ export async function resolveImportTarget(options: ResolveTargetOptions): Promis // Validate ARN format early if provided if ( arn && - !/^arn:[^:]+:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/.test(arn) + !/^arn:[^:]+:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config|gateway)\/(.+)$/.test( + arn + ) ) { throw new Error( - `Not a valid ARN: "${arn}".\nExpected format: arn::bedrock-agentcore:::/` + `Not a valid ARN: "${arn}".\nExpected format: arn::bedrock-agentcore:::/` ); } + // Detect region mismatch between caller's AWS_REGION and the ARN's region up front. + // Without this the ARN's region silently wins and the user can import cross-region + // by accident, leaving agentcore.json pointed at a region they didn't intend. + if (arn) { + const arnRegionMatch = /^arn:[^:]+:bedrock-agentcore:([^:]+):/.exec(arn); + const arnRegion = arnRegionMatch?.[1]; + const envRegion = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION; + if (arnRegion && envRegion && envRegion !== arnRegion) { + throw new Error( + `Region mismatch: AWS_REGION is "${envRegion}" but the ARN is in "${arnRegion}". ` + + `Either re-run with AWS_REGION=${arnRegion} or pass an ARN from ${envRegion}.` + ); + } + } + let targets = await configIO.readAWSDeploymentTargets(); if (targets.length === 0) { @@ -210,7 +227,7 @@ export interface ParsedArn { } const ARN_PATTERN = - /^arn:[^:]+:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/; + /^arn:[^:]+:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config|gateway)\/(.+)$/; /** Unified config for each importable resource type — ARN mapping, deployed state keys. */ const RESOURCE_TYPE_CONFIG: Record< @@ -229,6 +246,7 @@ const RESOURCE_TYPE_CONFIG: Record< collectionKey: 'onlineEvalConfigs', idField: 'onlineEvaluationConfigId', }, + gateway: { arnType: 'gateway', collectionKey: 'mcp.gateways', idField: 'gatewayId' }, }; /** @@ -302,7 +320,11 @@ export async function findResourceInDeployedState( const { collectionKey, idField } = RESOURCE_TYPE_CONFIG[resourceType]; - const collection = targetState.resources[collectionKey]; + // Handle nested path (e.g., 'mcp.gateways') by traversing dot-separated keys + let collection: any = targetState.resources; + for (const key of collectionKey.split('.')) { + collection = collection?.[key]; + } if (!collection) return undefined; for (const [name, entry] of Object.entries(collection)) { if ((entry as any)[idField] === resourceId) return name; @@ -360,6 +382,13 @@ export async function updateDeployedState( onlineEvaluationConfigId: resource.id, onlineEvaluationConfigArn: resource.arn, }; + } else if (resource.type === 'gateway') { + targetState.resources.mcp ??= {}; + targetState.resources.mcp.gateways ??= {}; + targetState.resources.mcp.gateways[resource.name] = { + gatewayId: resource.id, + gatewayArn: resource.arn, + }; } } diff --git a/src/cli/commands/import/resource-import.ts b/src/cli/commands/import/resource-import.ts index 6418e3676..a21a160ec 100644 --- a/src/cli/commands/import/resource-import.ts +++ b/src/cli/commands/import/resource-import.ts @@ -80,7 +80,7 @@ export async function executeResourceImport( } // 4. Validate name - const localName = options.name ?? descriptor.extractDetailName(detail); + let localName = options.name ?? descriptor.extractDetailName(detail); if (!NAME_REGEX.test(localName)) { return failResult( logger, @@ -111,13 +111,12 @@ export async function executeResourceImport( descriptor.resourceType, resourceId ); + const isReimport = !!existingResource; if (existingResource) { - return failResult( - logger, - `${descriptor.displayName} "${resourceId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, - descriptor.resourceType, - localName - ); + if (!options.name) { + localName = existingResource; + } + onProgress(`${descriptor.displayName} already managed by CloudFormation — re-adding to project config`); } logger.endStep('success'); @@ -158,13 +157,16 @@ export async function executeResourceImport( configIO: ctx.configIO, targetName, onProgress, - buildResourcesToImport: synthTemplate => { + buildResourcesToImport: (synthTemplate, deployedTemplate) => { + const deployedIds = new Set(Object.keys(deployedTemplate.Resources)); + // Try matching by name property (plain name first, then prefixed) let logicalId = findLogicalIdByProperty( synthTemplate, descriptor.cfnResourceType, descriptor.cfnNameProperty, - localName + localName, + { excludeLogicalIds: deployedIds } ); if (!logicalId) { @@ -173,13 +175,16 @@ export async function executeResourceImport( synthTemplate, descriptor.cfnResourceType, descriptor.cfnNameProperty, - prefixedName + prefixedName, + { excludeLogicalIds: deployedIds } ); } // Fall back to single resource by type if (!logicalId) { - const allLogicalIds = findLogicalIdsByType(synthTemplate, descriptor.cfnResourceType); + const allLogicalIds = findLogicalIdsByType(synthTemplate, descriptor.cfnResourceType).filter( + id => !deployedIds.has(id) + ); if (allLogicalIds.length === 1) { logicalId = allLogicalIds[0]; } @@ -201,6 +206,17 @@ export async function executeResourceImport( }); if (pipelineResult.noResources) { + if (isReimport) { + logger.endStep('success'); + logger.finalize(true); + return { + success: true, + resourceType: descriptor.resourceType, + resourceName: localName, + resourceId, + logPath: logger.getRelativeLogPath(), + }; + } const error = `Could not find logical ID for ${descriptor.displayName} "${localName}" in CloudFormation template`; await rollback(); return failResult(logger, error, descriptor.resourceType, localName); diff --git a/src/cli/commands/import/template-utils.ts b/src/cli/commands/import/template-utils.ts index 4e6a516af..e4d4f35e9 100644 --- a/src/cli/commands/import/template-utils.ts +++ b/src/cli/commands/import/template-utils.ts @@ -192,10 +192,14 @@ export function findLogicalIdByProperty( template: CfnTemplate, resourceType: string, propertyName: string, - propertyValue: string + propertyValue: string, + options?: { excludeLogicalIds?: ReadonlySet } ): string | undefined { + const exclude = options?.excludeLogicalIds; + // First pass: exact string match (highest confidence) for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (exclude?.has(logicalId)) continue; if (resource.Type === resourceType && resource.Properties) { if (resource.Properties[propertyName] === propertyValue) { return logicalId; @@ -211,6 +215,7 @@ export function findLogicalIdByProperty( const pattern = new RegExp(escaped + '(?=[^a-zA-Z0-9_]|$)'); for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (exclude?.has(logicalId)) continue; if (resource.Type === resourceType && resource.Properties) { const propVal = resource.Properties[propertyName]; if (typeof propVal === 'object' && propVal !== null) { diff --git a/src/cli/commands/import/types.ts b/src/cli/commands/import/types.ts index c2fa5d354..5d99c79c1 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -74,7 +74,7 @@ export interface ParsedStarterToolkitConfig { * Resource types supported by the import subcommands. * Use the array for runtime checks (e.g., IMPORTABLE_RESOURCES.includes(x)). */ -export const IMPORTABLE_RESOURCES = ['runtime', 'memory', 'evaluator', 'online-eval'] as const; +export const IMPORTABLE_RESOURCES = ['runtime', 'memory', 'evaluator', 'online-eval', 'gateway'] as const; export type ImportableResourceType = (typeof IMPORTABLE_RESOURCES)[number]; /** diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index c8c1bd68b..be25fd685 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,4 +1,5 @@ // Command registrations +export { registerArchive } from './archive'; export { registerAdd } from './add'; export { registerDeploy } from './deploy'; export { registerDev } from './dev'; @@ -11,6 +12,7 @@ export { registerPause } from './pause'; export { registerRemove } from './remove'; export { registerResume } from './resume'; export { registerRun } from './run'; +export { registerStop } from './stop'; export { registerStatus } from './status'; export { registerTraces } from './traces'; export { registerUpdate } from './update'; diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index 77d7fdccd..eb7aaaac2 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -94,6 +94,20 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption return { success: false, error: `Agent '${agentSpec.name}' is not deployed to target '${selectedTargetName}'` }; } + // Build config bundle baggage if a bundle is associated with this agent + const deployedBundles = targetState?.resources?.configBundles ?? {}; + let baggage: string | undefined; + const bundleSpec = project.configBundles?.find(b => { + const keys = Object.keys(b.components ?? {}); + return keys.some(k => k === `{{runtime:${agentSpec.name}}}`); + }); + if (bundleSpec) { + const bundleState = deployedBundles[bundleSpec.name]; + if (bundleState?.bundleArn && bundleState?.versionId) { + baggage = `aws.agentcore.configbundle_arn=${encodeURIComponent(bundleState.bundleArn)},aws.agentcore.configbundle_version=${encodeURIComponent(bundleState.versionId)}`; + } + } + // Auto-fetch bearer token for CUSTOM_JWT agents when not provided if (agentSpec.authorizerType === 'CUSTOM_JWT' && !options.bearerToken) { const canFetch = await canFetchRuntimeToken(agentSpec.name); @@ -230,6 +244,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption userId: options.userId, headers: options.headers, bearerToken: options.bearerToken, + baggage, }; // list-tools: list available MCP tools @@ -410,6 +425,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption logger, headers: options.headers, bearerToken: options.bearerToken, + baggage, }); for await (const chunk of result.stream) { @@ -443,6 +459,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption userId: options.userId, headers: options.headers, bearerToken: options.bearerToken, + baggage, }); logger.logResponse(response.content); diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 039acfb67..07d072320 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -60,6 +60,9 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }, deployedState: { targets: { @@ -121,6 +124,9 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }, }); const result = resolveAgentContext(context, {}); @@ -162,6 +168,9 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }, deployedState: { targets: { @@ -213,6 +222,9 @@ describe('resolveAgentContext', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/pause/__tests__/promote.test.ts b/src/cli/commands/pause/__tests__/promote.test.ts new file mode 100644 index 000000000..4b1ae200b --- /dev/null +++ b/src/cli/commands/pause/__tests__/promote.test.ts @@ -0,0 +1,59 @@ +import { waitForRunningThenStop } from '../promote-utils.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetABTest = vi.fn(); +const mockUpdateABTest = vi.fn(); + +vi.mock('../../../aws/agentcore-ab-tests', () => ({ + getABTest: (...args: unknown[]) => mockGetABTest(...args), + updateABTest: (...args: unknown[]) => mockUpdateABTest(...args), +})); + +describe('waitForRunningThenStop', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUpdateABTest.mockResolvedValue({ executionStatus: 'STOPPED' }); + }); + + it('stops immediately when already RUNNING', async () => { + mockGetABTest.mockResolvedValue({ executionStatus: 'RUNNING' }); + + await waitForRunningThenStop('us-east-1', 'abt-123', 'MyTest', 3, 0); + + expect(mockGetABTest).toHaveBeenCalledTimes(1); + expect(mockUpdateABTest).toHaveBeenCalledWith({ + region: 'us-east-1', + abTestId: 'abt-123', + executionStatus: 'STOPPED', + }); + }); + + it('polls until RUNNING then stops', async () => { + mockGetABTest + .mockResolvedValueOnce({ executionStatus: 'UPDATING' }) + .mockResolvedValueOnce({ executionStatus: 'UPDATING' }) + .mockResolvedValueOnce({ executionStatus: 'RUNNING' }); + + await waitForRunningThenStop('us-east-1', 'abt-123', 'MyTest', 5, 0); + + expect(mockGetABTest).toHaveBeenCalledTimes(3); + expect(mockUpdateABTest).toHaveBeenCalledOnce(); + }); + + it('throws if AB test never reaches RUNNING', async () => { + mockGetABTest.mockResolvedValue({ executionStatus: 'UPDATING' }); + + await expect(waitForRunningThenStop('us-east-1', 'abt-123', 'MyTest', 3, 0)).rejects.toThrow( + 'did not reach RUNNING state' + ); + + expect(mockGetABTest).toHaveBeenCalledTimes(3); + expect(mockUpdateABTest).not.toHaveBeenCalled(); + }); + + it('includes current status in the error message', async () => { + mockGetABTest.mockResolvedValue({ executionStatus: 'STOPPED' }); + + await expect(waitForRunningThenStop('us-east-1', 'abt-123', 'MyTest', 2, 0)).rejects.toThrow('current: STOPPED'); + }); +}); diff --git a/src/cli/commands/pause/command.tsx b/src/cli/commands/pause/command.tsx index 5a3183ea9..e99e266aa 100644 --- a/src/cli/commands/pause/command.tsx +++ b/src/cli/commands/pause/command.tsx @@ -1,8 +1,13 @@ +import { ConfigIO } from '../../../lib'; +import { listABTests, updateABTest } from '../../aws/agentcore-ab-tests'; +import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; import { getErrorMessage } from '../../errors'; import { handlePauseResume } from '../../operations/eval'; import type { OnlineEvalActionOptions } from '../../operations/eval'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; +import { getRegion } from '../shared/region-utils'; +import { waitForRunningThenStop } from './promote-utils'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; import React from 'react'; @@ -67,12 +72,250 @@ function registerOnlineEvalSubcommand(parent: Command, action: 'pause' | 'resume }); } +async function resolveABTestId( + testName: string, + region: string +): Promise<{ abTestId: string; region: string; error?: string }> { + let projectName: string | undefined; + try { + const configIO = new ConfigIO(); + const deployedState = await configIO.readDeployedState(); + const awsTargets = await configIO.readAWSDeploymentTargets(); + + try { + const projectSpec = await configIO.readProjectSpec(); + projectName = projectSpec.name; + } catch { + // Project spec unavailable + } + + for (const [targetName, target] of Object.entries(deployedState.targets ?? {})) { + const abTests = target.resources?.abTests; + if (abTests?.[testName]) { + const targetConfig = awsTargets.find(t => t.name === targetName); + const resolvedRegion = targetConfig?.region ?? region; + return { abTestId: abTests[testName].abTestId, region: resolvedRegion }; + } + } + } catch { + // No deployed state + } + + try { + const result = await listABTests({ region, maxResults: 100 }); + // Match against both prefixed name ({projectName}_{testName}) and bare testName (backwards compat) + const prefixedName = projectName ? `${projectName}_${testName}` : undefined; + const match = + result.abTests.find(t => prefixedName != null && t.name === prefixedName) ?? + result.abTests.find(t => t.name === testName); + if (match) return { abTestId: match.abTestId, region }; + } catch { + // API call failed + } + + return { abTestId: '', region, error: `AB test "${testName}" not found in deployed state or API.` }; +} + +function registerABTestSubcommand(parent: Command, action: 'pause' | 'resume') { + const executionStatus = action === 'pause' ? 'PAUSED' : 'RUNNING'; + const pastTense = action === 'pause' ? 'Paused' : 'Resumed'; + + parent + .command('ab-test') + .description(`[preview] ${action === 'pause' ? 'Pause' : 'Resume'} a deployed A/B test`) + .argument('', 'AB test name') + .option('--region ', 'AWS region') + .option('--json', 'Output as JSON') + .action(async (name: string, cliOptions: { region?: string; json?: boolean }) => { + try { + const region = await getRegion(cliOptions.region); + const { abTestId, error } = await resolveABTestId(name, region); + if (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + const result = await updateABTest({ + region, + abTestId, + executionStatus, + }); + + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, ...result })); + } else { + console.log(`${pastTense} AB test "${name}" (execution: ${result.executionStatus})`); + } + process.exit(0); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + }); +} + export const registerPause = (program: Command) => { const pauseCmd = program.command('pause').description(COMMAND_DESCRIPTIONS.pause); registerOnlineEvalSubcommand(pauseCmd, 'pause'); + registerABTestSubcommand(pauseCmd, 'pause'); }; export const registerResume = (program: Command) => { const resumeCmd = program.command('resume').description(COMMAND_DESCRIPTIONS.resume); registerOnlineEvalSubcommand(resumeCmd, 'resume'); + registerABTestSubcommand(resumeCmd, 'resume'); +}; + +export const registerStop = (program: Command) => { + const stopCmd = program.command('stop').description('Stop resources'); + + stopCmd + .command('ab-test') + .description('[preview] Stop a deployed A/B test permanently') + .argument('', 'AB test name') + .option('--region ', 'AWS region') + .option('--json', 'Output as JSON') + .action(async (name: string, cliOptions: { region?: string; json?: boolean }) => { + try { + const region = await getRegion(cliOptions.region); + const { abTestId, error } = await resolveABTestId(name, region); + if (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + const result = await updateABTest({ + region, + abTestId, + executionStatus: 'STOPPED', + }); + + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, ...result })); + } else { + console.log(`Stopped AB test "${name}" (execution: ${result.executionStatus})`); + } + process.exit(0); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + }); + + stopCmd + .command('batch-evaluation') + .description('[preview] Stop a running batch evaluation') + .requiredOption('-i, --id ', 'Batch evaluation ID to stop') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--json', 'Output as JSON') + .action(async (cliOptions: { id: string; region?: string; json?: boolean }) => { + try { + const region = await getRegion(cliOptions.region); + + const result = await stopBatchEvaluation({ + region, + batchEvaluationId: cliOptions.id, + }); + + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, ...result })); + } else { + console.log(`\nBatch evaluation stopped successfully`); + console.log(`ID: ${result.batchEvaluationId}`); + console.log(`Status: ${result.status}\n`); + } + + process.exit(0); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + render(Error: {getErrorMessage(error)}); + } + process.exit(1); + } + }); +}; + +export const registerPromote = (program: Command) => { + const promoteCmd = program.command('promote').description('Promote resources'); + + promoteCmd + .command('ab-test') + .description('Promote the winning treatment of an A/B test') + .argument('', 'AB test name') + .option('--region ', 'AWS region') + .option('--json', 'Output as JSON') + .action(async (name: string, cliOptions: { region?: string; json?: boolean }) => { + try { + const region = await getRegion(cliOptions.region); + const { abTestId, error } = await resolveABTestId(name, region); + if (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + const result = await waitForRunningThenStop(region, abTestId, name); + + // Apply promotion to agentcore.json + const { promoteABTestConfig } = await import('../../operations/ab-test/promote'); + let promoted = false; + let mode: string | undefined; + let promotionDetail = ''; + try { + const promoResult = await promoteABTestConfig(abTestId, name); + promoted = promoResult.promoted; + mode = promoResult.mode; + promotionDetail = promoResult.promotionDetail; + } catch { + // Config read/write failed + } + + if (cliOptions.json) { + console.log( + JSON.stringify({ + success: true, + ...result, + ...(mode && { mode }), + promoted, + ...(promotionDetail && { promotionDetail }), + }) + ); + } else { + console.log(`AB test "${name}" stopped.`); + if (promoted) { + console.log(`\n${promotionDetail}`); + console.log(`\nRun: agentcore deploy`); + } + } + process.exit(0); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + }); }; diff --git a/src/cli/commands/pause/index.ts b/src/cli/commands/pause/index.ts index 858054fd2..1bc38e3be 100644 --- a/src/cli/commands/pause/index.ts +++ b/src/cli/commands/pause/index.ts @@ -1 +1 @@ -export { registerPause } from './command'; +export { registerPause, registerPromote } from './command'; diff --git a/src/cli/commands/pause/promote-utils.ts b/src/cli/commands/pause/promote-utils.ts new file mode 100644 index 000000000..9bca03f8a --- /dev/null +++ b/src/cli/commands/pause/promote-utils.ts @@ -0,0 +1,28 @@ +import { getABTest, updateABTest } from '../../aws/agentcore-ab-tests'; +import type { UpdateABTestResult } from '../../aws/agentcore-ab-tests'; + +/** + * Poll until the AB test reaches RUNNING status, then stop it. + * Throws if the test never reaches RUNNING within the allotted attempts. + */ +export async function waitForRunningThenStop( + region: string, + abTestId: string, + name: string, + maxAttempts = 12, + delayMs = 10_000 +): Promise { + let currentStatus: string | undefined; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const current = await getABTest({ region, abTestId }); + currentStatus = current.executionStatus; + if (currentStatus === 'RUNNING') break; + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + if (currentStatus !== 'RUNNING') { + throw new Error( + `AB test "${name}" did not reach RUNNING state after waiting (current: ${currentStatus}). Cannot promote.` + ); + } + return updateABTest({ region, abTestId, executionStatus: 'STOPPED' }); +} diff --git a/src/cli/commands/recommendations/command.tsx b/src/cli/commands/recommendations/command.tsx new file mode 100644 index 000000000..bcf3b2784 --- /dev/null +++ b/src/cli/commands/recommendations/command.tsx @@ -0,0 +1,63 @@ +import { getErrorMessage } from '../../errors'; +import { listAllRecommendations } from '../../operations/recommendation'; +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; +import { requireProject } from '../../tui/guards'; +import type { Command } from '@commander-js/extra-typings'; +import { Text, render } from 'ink'; +import React from 'react'; + +export const registerRecommendations = (program: Command) => { + const recCmd = program.command('recommendations').description(COMMAND_DESCRIPTIONS.recommendations); + + recCmd + .command('history') + .description('Show past recommendation runs saved locally') + .option('--json', 'Output as JSON') + .action((cliOptions: { json?: boolean }) => { + requireProject(); + + try { + const records = listAllRecommendations(); + + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, recommendations: records })); + process.exit(0); + return; + } + + if (records.length === 0) { + console.log('No recommendation runs found. Run `agentcore run recommendation` to create one.'); + return; + } + + console.log( + `\n${'Date'.padEnd(22)} ${'Type'.padEnd(20)} ${'Agent'.padEnd(20)} ${'Recommendation ID'.padEnd(40)}` + ); + console.log('─'.repeat(105)); + + for (const record of records) { + const date = record.startedAt + ? new Date(record.startedAt).toLocaleString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : 'unknown'; + console.log( + `${date.padEnd(22)} ${(record.type ?? 'unknown').padEnd(20)} ${(record.agent ?? 'unknown').padEnd(20)} ${record.recommendationId.padEnd(40)}` + ); + } + + console.log(''); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + render(Error: {getErrorMessage(error)}); + } + process.exit(1); + } + }); +}; diff --git a/src/cli/commands/recommendations/index.ts b/src/cli/commands/recommendations/index.ts new file mode 100644 index 000000000..8c0a96809 --- /dev/null +++ b/src/cli/commands/recommendations/index.ts @@ -0,0 +1 @@ +export { registerRecommendations } from './command'; diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index e978e9793..05e532688 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -34,6 +34,9 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise = { + 'system-prompt': 'SYSTEM_PROMPT_RECOMMENDATION', + 'tool-description': 'TOOL_DESCRIPTION_RECOMMENDATION', +}; + function formatRunOutput(result: Awaited>): void { if (!result.run) return; @@ -59,7 +76,7 @@ export const registerRun = (program: Command) => { ) .option('-r, --runtime ', 'Runtime name from project config') .option('--runtime-arn ', 'Runtime ARN — run outside a project directory') - .option('-e, --evaluator ', 'Evaluator name(s) from project or Builtin.* IDs') + .option('-e, --evaluator ', 'Evaluator name(s) — project evaluators or Builtin.* IDs') .option('--evaluator-arn ', 'Evaluator ARN(s) — use with --runtime-arn for standalone mode') .option('--region ', 'AWS region (required with --runtime-arn, auto-detected otherwise)') .option('-s, --session-id ', 'Evaluate a specific session only') @@ -69,9 +86,9 @@ export const registerRun = (program: Command) => { 'Runtime endpoint name (e.g. PROMPT_V1). Defaults to AGENTCORE_RUNTIME_ENDPOINT env var, then DEFAULT' ) .option('--days ', 'Lookback window in days', '7') - .option('-A, --assertion ', 'Assertion the agent should satisfy (repeatable)') - .option('--expected-trajectory ', 'Expected tool calls in order (comma-separated)') - .option('--expected-response ', 'Expected agent response text') + .option('-A, --assertion ', 'Ground truth assertion the agent response must satisfy (repeatable)') + .option('--expected-trajectory ', 'Ground truth: expected tool call names in order (comma-separated)') + .option('--expected-response ', 'Ground truth: expected agent response text to compare against') .option('--output ', 'Custom output file path for results') .option('--json', 'Output as JSON') .action( @@ -148,4 +165,386 @@ export const registerRun = (program: Command) => { } } ); + + runCmd + .command('batch-evaluation') + .description('[preview] Run evaluators in batch across all agent sessions in CloudWatch') + .requiredOption('-r, --runtime ', 'Runtime name from project config') + .requiredOption('-e, --evaluator ', 'Evaluator name(s) — Builtin.* IDs') + .option('-n, --name ', 'Name for the batch evaluation (auto-generated if omitted)') + .option('-d, --lookback-days ', 'Lookback window in days (filters sessions by time range)') + .option('-s, --session-ids ', 'Specific session IDs to evaluate') + .option( + '-g, --ground-truth ', + 'JSON file with session metadata and ground truth (assertions, expected trajectory, turns)' + ) + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + runtime: string; + evaluator: string[]; + name?: string; + lookbackDays?: string; + sessionIds?: string[]; + groundTruth?: string; + region?: string; + json?: boolean; + }) => { + requireProject(); + + try { + // Parse ground truth file if provided + let sessionMetadata: import('../../aws/agentcore-batch-evaluation').SessionMetadataEntry[] | undefined; + if (cliOptions.groundTruth) { + const { readFileSync } = await import('node:fs'); + const gtContent = readFileSync(cliOptions.groundTruth, 'utf-8'); + const gtData = JSON.parse(gtContent) as Record; + // Accept either a raw array or an object with a sessionMetadata key + sessionMetadata = Array.isArray(gtData) + ? (gtData as import('../../aws/agentcore-batch-evaluation').SessionMetadataEntry[]) + : (gtData.sessionMetadata as import('../../aws/agentcore-batch-evaluation').SessionMetadataEntry[]); + if (!Array.isArray(sessionMetadata)) { + throw new Error( + 'Ground truth file must be a JSON array of session metadata entries, or an object with a "sessionMetadata" key' + ); + } + } + + const lookbackDays = cliOptions.lookbackDays ? parseInt(cliOptions.lookbackDays, 10) : undefined; + const result = await runBatchEvaluationCommand({ + agent: cliOptions.runtime, + evaluators: cliOptions.evaluator, + name: cliOptions.name, + region: cliOptions.region, + sessionIds: cliOptions.sessionIds, + lookbackDays: lookbackDays && !isNaN(lookbackDays) ? lookbackDays : undefined, + sessionMetadata, + onProgress: cliOptions.json + ? undefined + : (_status, message) => { + console.log(message); + }, + }); + + // Save results locally + if (result.success) { + try { + const filePath = saveBatchEvalRun(result); + if (!cliOptions.json) { + console.log(`\nResults saved to: ${filePath}`); + } + } catch { + // Non-fatal — skip saving + } + } + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + formatBatchEvalOutput(result); + } else { + render({result.error}); + if (result.logFilePath) { + console.error(`\nLog: ${result.logFilePath}`); + } + } + + process.exit(result.success ? 0 : 1); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + render(Error: {getErrorMessage(error)}); + } + process.exit(1); + } + } + ); + + runCmd + .command('recommendation') + .description('[preview] Optimize a system prompt or tool descriptions using agent traces as signal') + .option('-t, --type ', 'What to optimize: system-prompt or tool-description (default: system-prompt)') + .option('-r, --runtime ', 'Runtime name from project config') + .option('-e, --evaluator ', 'Evaluator name — required for system-prompt (exactly one)') + .option('--prompt-file ', 'Load the current system prompt from a file') + .option('--inline ', 'Provide the current system prompt or tool descriptions inline') + .option('--bundle-name ', 'Read current content from a deployed config bundle') + .option('--bundle-version ', 'Config bundle version (used with --bundle-name)') + .option( + '--system-prompt-json-path ', + 'Field name under "configuration" in the bundle (e.g. "systemPrompt"). The CLI resolves it to the full path automatically. Do not use bracket notation — use dot notation only.' + ) + .option( + '--tool-desc-json-path ', + 'Tool name:field pairs for tool descriptions in a config bundle (e.g. --tool-desc-json-path "search:searchDesc"). The CLI resolves each to the full path automatically.' + ) + .option( + '--tools ', + 'Tool name:description pairs (repeatable, e.g. --tools "search:Searches the web" --tools "calc:Does math")' + ) + .option('--spans-file ', 'JSON file with OTEL session spans (use instead of CloudWatch traces)') + .option('--lookback ', 'How far back to search for traces in CloudWatch (days)', '7') + .option('-s, --session-id ', 'Limit trace collection to specific session IDs') + .option('-n, --run ', 'Run name prefix for the recommendation') + .option('--region ', 'AWS region') + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + type?: string; + runtime?: string; + evaluator?: string; + promptFile?: string; + inline?: string; + bundleName?: string; + bundleVersion?: string; + systemPromptJsonPath?: string; + toolDescJsonPath?: string[]; + tools?: string[]; + spansFile?: string; + lookback: string; + sessionId?: string[]; + run?: string; + region?: string; + json?: boolean; + }) => { + requireProject(); + + const typeKey = cliOptions.type ?? 'system-prompt'; + const recType = RECOMMENDATION_TYPE_MAP[typeKey]; + if (!recType) { + const error = `Invalid --type "${typeKey}". Must be one of: ${Object.keys(RECOMMENDATION_TYPE_MAP).join(', ')}`; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + render({error}); + } + process.exit(1); + } + + const agent = cliOptions.runtime; + const evaluator = cliOptions.evaluator; + + if (!agent) { + const error = '--runtime is required'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + render({error}); + } + process.exit(1); + } + + // Evaluator is required for system-prompt recs, optional for tool-description + if (recType === 'SYSTEM_PROMPT_RECOMMENDATION' && !evaluator) { + const error = '--evaluator is required for system-prompt recommendations'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + render({error}); + } + process.exit(1); + } + + try { + const inputSource = cliOptions.promptFile + ? ('file' as const) + : cliOptions.inline + ? ('inline' as const) + : cliOptions.bundleName + ? ('config-bundle' as const) + : ('inline' as const); + + const traceSource = cliOptions.spansFile + ? ('spans-file' as const) + : cliOptions.sessionId + ? ('sessions' as const) + : ('cloudwatch' as const); + + // Parse --tool-desc-json-path pairs ("toolName:$.json.path") into structured format + const toolDescJsonPaths = cliOptions.toolDescJsonPath + ?.map(pair => { + const colonIdx = pair.indexOf(':'); + if (colonIdx <= 0) return undefined; + return { + toolName: pair.slice(0, colonIdx), + toolDescriptionJsonPath: pair.slice(colonIdx + 1), + }; + }) + .filter((p): p is { toolName: string; toolDescriptionJsonPath: string } => p !== undefined); + + const result = await runRecommendationCommand({ + type: recType, + agent, + evaluators: evaluator ? [evaluator] : [], + promptFile: cliOptions.promptFile, + inlineContent: cliOptions.inline, + bundleName: cliOptions.bundleName, + bundleVersion: cliOptions.bundleVersion, + systemPromptJsonPath: cliOptions.systemPromptJsonPath, + toolDescJsonPaths: toolDescJsonPaths?.length ? toolDescJsonPaths : undefined, + tools: cliOptions.tools, + lookbackDays: parseInt(cliOptions.lookback, 10), + sessionIds: cliOptions.sessionId, + spansFile: cliOptions.spansFile, + recommendationName: cliOptions.run, + region: cliOptions.region, + inputSource, + traceSource, + onProgress: cliOptions.json + ? undefined + : (_status, message) => { + console.log(message); + }, + }); + + if (!result.success) { + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else { + render({result.error}); + if (result.logFilePath) { + console.error(`\nLog: ${result.logFilePath}`); + } + } + process.exit(1); + } + + // Save results locally + let savedFilePath: string | undefined; + try { + if (result.recommendationId) { + savedFilePath = saveRecommendationRun( + result.recommendationId, + result, + recType, + agent, + evaluator ? [evaluator] : [] + ); + } + } catch { + // Non-fatal — skip saving + } + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else { + console.log(`\nRecommendation ID: ${result.recommendationId}`); + + if (result.result) { + const sysResult = result.result.systemPromptRecommendationResult; + const toolResult = result.result.toolDescriptionRecommendationResult; + + if (sysResult) { + if (sysResult.recommendedSystemPrompt) { + console.log('\n+++ Recommended System Prompt +++'); + console.log(sysResult.recommendedSystemPrompt); + } + } else if (toolResult?.tools) { + for (const tool of toolResult.tools) { + console.log(`\nTool: ${tool.toolName}`); + console.log(`Recommended: ${tool.recommendedToolDescription}`); + } + } + } + + if (savedFilePath) { + console.log(`\nResults saved to: ${savedFilePath}`); + } + + // Sync local config bundle after server-side recommendation apply + if (inputSource === 'config-bundle' && cliOptions.bundleName && result.result && result.region) { + try { + const applyResult = await applyRecommendationToBundle({ + bundleName: cliOptions.bundleName, + result: result.result, + region: result.region, + }); + if (applyResult.success) { + console.log( + `\nA new config bundle version (${applyResult.newVersionId}) was created with the recommended changes.` + ); + console.log(`Local config for "${cliOptions.bundleName}" has been updated to match.`); + } else { + console.log(`\nCould not sync config bundle: ${applyResult.error}`); + } + } catch { + // Non-fatal — user can manually sync + } + } + console.log(''); + } + + process.exit(0); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + render(Error: {getErrorMessage(error)}); + } + process.exit(1); + } + } + ); }; + +function formatBatchEvalOutput(result: RunBatchEvaluationCommandResult): void { + console.log(`\nBatch Evaluation: ${result.name ?? result.batchEvaluationId}`); + console.log(`ID: ${result.batchEvaluationId}`); + console.log(`Status: ${result.status}`); + + // Show session stats from API if available + const evalResults = result.evaluationResults; + if (evalResults) { + const parts: string[] = []; + if (evalResults.totalNumberOfSessions != null) parts.push(`${evalResults.totalNumberOfSessions} sessions`); + if (evalResults.numberOfSessionsCompleted != null) parts.push(`${evalResults.numberOfSessionsCompleted} completed`); + if (evalResults.numberOfSessionsFailed) parts.push(`${evalResults.numberOfSessionsFailed} failed`); + if (parts.length > 0) console.log(`Sessions: ${parts.join(', ')}`); + } + + console.log(''); + + // Prefer API evaluatorSummaries over local computation + const summaries = evalResults?.evaluatorSummaries; + if (summaries && summaries.length > 0) { + for (const s of summaries) { + const avg = s.statistics?.averageScore; + const avgStr = avg != null ? avg.toFixed(2) : 'N/A'; + const failSuffix = s.totalFailed ? ` (${s.totalFailed} failed)` : ''; + const evalCount = s.totalEvaluated != null ? ` [${s.totalEvaluated} evaluated]` : ''; + console.log(` ${s.evaluatorId}: ${avgStr} avg${failSuffix}${evalCount}`); + } + } else if (result.results.length > 0) { + // Fall back to local computation from CloudWatch results + const byEvaluator = new Map(); + for (const r of result.results) { + const group = byEvaluator.get(r.evaluatorId) ?? []; + group.push(r); + byEvaluator.set(r.evaluatorId, group); + } + + for (const [evalId, evalGroup] of byEvaluator) { + const scores = evalGroup.filter(r => !r.error).map(r => r.score!); + const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0; + const errors = evalGroup.filter(r => r.error).length; + const errorSuffix = errors > 0 ? ` (${errors} errors)` : ''; + + console.log(` ${evalId}: ${avg.toFixed(2)} avg${errorSuffix}`); + + for (const r of evalGroup) { + if (r.error) { + console.log(` ERROR: ${r.error.slice(0, 80)}`); + } else { + const labelStr = r.label ? ` (${r.label})` : ''; + console.log(` ${r.score?.toFixed(2)}${labelStr}`); + } + } + } + } else { + console.log(' No evaluation results found.'); + } + + console.log(''); +} diff --git a/src/cli/commands/shared/__tests__/region-utils.test.ts b/src/cli/commands/shared/__tests__/region-utils.test.ts new file mode 100644 index 000000000..4e44686d8 --- /dev/null +++ b/src/cli/commands/shared/__tests__/region-utils.test.ts @@ -0,0 +1,130 @@ +import { getRegion } from '../region-utils.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockResolveAWSDeploymentTargets = vi.fn(); + +vi.mock('../../../../lib', () => ({ + ConfigIO: function () { + return { resolveAWSDeploymentTargets: () => mockResolveAWSDeploymentTargets() }; + }, +})); + +describe('getRegion', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_REGION; + }); + + afterEach(() => { + process.env = originalEnv; + vi.clearAllMocks(); + }); + + describe('explicit cliRegion argument', () => { + it('returns cliRegion immediately without consulting ConfigIO or env vars', async () => { + process.env.AWS_DEFAULT_REGION = 'eu-central-1'; + mockResolveAWSDeploymentTargets.mockResolvedValue([{ region: 'ap-southeast-1' }]); + + const result = await getRegion('us-west-2'); + + expect(result).toBe('us-west-2'); + expect(mockResolveAWSDeploymentTargets).not.toHaveBeenCalled(); + }); + }); + + describe('project config fallback', () => { + it('returns first target region from project config when no cliRegion', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([{ region: 'ap-northeast-1' }, { region: 'us-east-1' }]); + + const result = await getRegion(); + + expect(result).toBe('ap-northeast-1'); + }); + + it('falls through to env vars when resolveAWSDeploymentTargets returns empty array', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([]); + process.env.AWS_DEFAULT_REGION = 'eu-west-1'; + + const result = await getRegion(); + + expect(result).toBe('eu-west-1'); + }); + + it('falls through to env vars when resolveAWSDeploymentTargets throws', async () => { + mockResolveAWSDeploymentTargets.mockRejectedValue(new Error('No agentcore project found')); + process.env.AWS_DEFAULT_REGION = 'eu-west-2'; + + const result = await getRegion(); + + expect(result).toBe('eu-west-2'); + }); + + it('does not throw when ConfigIO constructor throws', async () => { + mockResolveAWSDeploymentTargets.mockRejectedValue(new Error('fs error')); + process.env.AWS_REGION = 'us-west-1'; + + await expect(getRegion()).resolves.toBe('us-west-1'); + }); + }); + + describe('environment variable fallback', () => { + it('prefers AWS_DEFAULT_REGION over AWS_REGION', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([]); + process.env.AWS_DEFAULT_REGION = 'eu-central-1'; + process.env.AWS_REGION = 'us-west-2'; + + const result = await getRegion(); + + expect(result).toBe('eu-central-1'); + }); + + it('falls back to AWS_REGION when AWS_DEFAULT_REGION is unset', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([]); + process.env.AWS_REGION = 'ap-south-1'; + + const result = await getRegion(); + + expect(result).toBe('ap-south-1'); + }); + + it('returns us-east-1 when no region is configured anywhere', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([]); + + const result = await getRegion(); + + expect(result).toBe('us-east-1'); + }); + + it('returns us-east-1 when ConfigIO throws and no env vars are set', async () => { + mockResolveAWSDeploymentTargets.mockRejectedValue(new Error('project not found')); + + const result = await getRegion(); + + expect(result).toBe('us-east-1'); + }); + }); + + describe('fallback priority order', () => { + it('uses cliRegion > project config > AWS_DEFAULT_REGION > AWS_REGION > us-east-1', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([{ region: 'project-region' }]); + process.env.AWS_DEFAULT_REGION = 'env-default-region'; + process.env.AWS_REGION = 'env-region'; + + expect(await getRegion('explicit-region')).toBe('explicit-region'); + + expect(await getRegion(undefined)).toBe('project-region'); + + mockResolveAWSDeploymentTargets.mockResolvedValue([]); + expect(await getRegion(undefined)).toBe('env-default-region'); + + delete process.env.AWS_DEFAULT_REGION; + expect(await getRegion(undefined)).toBe('env-region'); + + delete process.env.AWS_REGION; + expect(await getRegion(undefined)).toBe('us-east-1'); + }); + }); +}); diff --git a/src/cli/commands/shared/region-utils.ts b/src/cli/commands/shared/region-utils.ts new file mode 100644 index 000000000..6013ae823 --- /dev/null +++ b/src/cli/commands/shared/region-utils.ts @@ -0,0 +1,13 @@ +import { ConfigIO } from '../../../lib'; + +export async function getRegion(cliRegion?: string): Promise { + if (cliRegion) return cliRegion; + try { + const configIO = new ConfigIO(); + const targets = await configIO.resolveAWSDeploymentTargets(); + if (targets.length > 0) return targets[0]!.region; + } catch { + // Fall through to env vars + } + return process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; +} diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 11d535e3e..271b05bc9 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -2,6 +2,7 @@ import { ConfigIO } from '../../../lib'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedResourceState, DeployedState } from '../../../schema'; import { getAgentRuntimeStatus } from '../../aws'; import { getEvaluator, getOnlineEvaluationConfig } from '../../aws/agentcore-control'; +import { dnsSuffix } from '../../aws/partition'; import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; import type { ResourceDeploymentState } from './constants'; @@ -19,6 +20,8 @@ export interface ResourceStatusEntry { | 'online-eval' | 'policy-engine' | 'policy' + | 'config-bundle' + | 'ab-test' | 'runtime-endpoint'; name: string; deploymentState: ResourceDeploymentState; @@ -124,6 +127,30 @@ function diffResourceSet({ return entries; } +/** + * Build the full gateway invocation URL for an AB test. + * Appends the runtime target name and /invocations path to the gateway base URL. + */ +function buildGatewayInvocationUrl( + gwState: { gatewayId: string; gatewayArn: string; gatewayUrl?: string }, + gwName: string, + project: AgentCoreProjectSpec +): string | undefined { + // Use stored URL or derive from ARN: arn:aws:bedrock-agentcore:{region}:{account}:gateway/{id} + const baseUrl = + gwState.gatewayUrl ?? + (() => { + const region = gwState.gatewayArn.split(':')[3]; + return region + ? `https://${gwState.gatewayId}.gateway.bedrock-agentcore.${region}.${dnsSuffix(region)}` + : undefined; + })(); + if (!baseUrl) return undefined; + const gwSpec = (project.httpGateways ?? []).find(gw => gw.name === gwName); + if (!gwSpec) return baseUrl; + return `${baseUrl}/${gwSpec.runtimeRef}/invocations`; +} + export function computeResourceStatuses( project: AgentCoreProjectSpec, resources: DeployedResourceState | undefined @@ -210,6 +237,37 @@ export function computeResourceStatuses( getDeployedKey: item => `${item.engineName}/${item.name}`, }); + const configBundles = diffResourceSet({ + resourceType: 'config-bundle', + localItems: project.configBundles ?? [], + deployedRecord: resources?.configBundles ?? {}, + getIdentifier: deployed => deployed.bundleArn, + getLocalDetail: item => item.description, + }); + + const abTests = diffResourceSet({ + resourceType: 'ab-test', + localItems: project.abTests ?? [], + deployedRecord: resources?.abTests ?? {}, + getIdentifier: deployed => deployed.abTestArn, + getLocalDetail: item => item.description, + }); + + // Enrich deployed AB tests with gateway invocation URL + const httpGatewayState = resources?.httpGateways ?? {}; + for (const entry of abTests) { + if (entry.deploymentState !== 'deployed') continue; + const testSpec = (project.abTests ?? []).find(t => t.name === entry.name); + if (!testSpec) continue; + const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(testSpec.gatewayRef); + const gwName = gwMatch?.[1]; + if (!gwName) continue; + const gwState = httpGatewayState[gwName]; + if (!gwState) continue; + const url = buildGatewayInvocationUrl(gwState, gwName, project); + if (url) entry.invocationUrl = url; + } + // Flatten runtime endpoints for diffing against deployed state const localEndpoints: { name: string; agentName: string; version: number; description?: string }[] = []; for (const runtime of project.runtimes) { @@ -245,6 +303,8 @@ export function computeResourceStatuses( ...onlineEvalConfigs, ...policyEngines, ...policies, + ...configBundles, + ...abTests, ]; } diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index 76c4580ea..506ad10ec 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -17,6 +17,8 @@ const VALID_RESOURCE_TYPES = [ 'online-eval', 'policy-engine', 'policy', + 'config-bundle', + 'ab-test', ] as const; const VALID_STATES = ['deployed', 'local-only', 'pending-removal'] as const; @@ -59,7 +61,7 @@ export const registerStatus = (program: Command) => { .option('--target ', 'Select deployment target') .option( '--type ', - 'Filter by resource type (agent, runtime-endpoint, memory, credential, gateway, evaluator, online-eval, policy-engine, policy)' + 'Filter by resource type (agent, runtime-endpoint, memory, credential, gateway, evaluator, online-eval, policy-engine, policy, config-bundle, ab-test)' ) .option('--state ', 'Filter by deployment state (deployed, local-only, pending-removal)') .option('--runtime ', 'Filter to a specific runtime') @@ -144,11 +146,14 @@ export const registerStatus = (program: Command) => { const onlineEvals = filtered.filter(r => r.resourceType === 'online-eval'); const policyEngines = filtered.filter(r => r.resourceType === 'policy-engine'); const policies = filtered.filter(r => r.resourceType === 'policy'); + const configBundles = filtered.filter(r => r.resourceType === 'config-bundle'); + const abTests = filtered.filter(r => r.resourceType === 'ab-test'); + // TODO: Add http-gateway resource type when diffResourceSet for HTTP gateways is added to action.ts render( - AgentCore Status (target: {result.targetName} + AgentCore Status (target: {result.targetName || 'No target configured'} {result.targetRegion ? `, ${result.targetRegion}` : ''}) @@ -257,6 +262,33 @@ export const registerStatus = (program: Command) => { )} + {configBundles.length > 0 && ( + + Config Bundles + {configBundles.map(entry => ( + + ))} + + )} + + {abTests.length > 0 && ( + + AB Tests + {abTests.map(entry => ( + + + {entry.invocationUrl && ( + + {' '}Invocation URL: {entry.invocationUrl} + + )} + + ))} + + )} + + {/* TODO: Add HTTP Gateways render section when diffResourceSet is added to action.ts */} + {filtered.length === 0 && No resources match the given filters.} ); diff --git a/src/cli/commands/stop/command.tsx b/src/cli/commands/stop/command.tsx new file mode 100644 index 000000000..74d6665f2 --- /dev/null +++ b/src/cli/commands/stop/command.tsx @@ -0,0 +1,45 @@ +import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; +import { getErrorMessage } from '../../errors'; +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; +import { getRegion } from '../shared/region-utils'; +import type { Command } from '@commander-js/extra-typings'; +import { Text, render } from 'ink'; +import React from 'react'; + +export const registerStop = (program: Command) => { + const stopCmd = program.command('stop').description(COMMAND_DESCRIPTIONS.stop); + + stopCmd + .command('batch-evaluation') + .description('[preview] Stop a running batch evaluation') + .requiredOption('-i, --id ', 'Batch evaluation ID to stop') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--json', 'Output as JSON') + .action(async (cliOptions: { id: string; region?: string; json?: boolean }) => { + try { + const region = await getRegion(cliOptions.region); + + const result = await stopBatchEvaluation({ + region, + batchEvaluationId: cliOptions.id, + }); + + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, ...result })); + } else { + console.log(`\nBatch evaluation stopped successfully`); + console.log(`ID: ${result.batchEvaluationId}`); + console.log(`Status: ${result.status}\n`); + } + + process.exit(0); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + render(Error: {getErrorMessage(error)}); + } + process.exit(1); + } + }); +}; diff --git a/src/cli/commands/stop/index.ts b/src/cli/commands/stop/index.ts new file mode 100644 index 000000000..1f1a5e1e2 --- /dev/null +++ b/src/cli/commands/stop/index.ts @@ -0,0 +1 @@ +export { registerStop } from '../pause/command'; diff --git a/src/cli/commands/telemetry/__tests__/telemetry.test.ts b/src/cli/commands/telemetry/__tests__/telemetry.test.ts index b0e615fcd..efdfd2f23 100644 --- a/src/cli/commands/telemetry/__tests__/telemetry.test.ts +++ b/src/cli/commands/telemetry/__tests__/telemetry.test.ts @@ -1,5 +1,5 @@ +import { readGlobalConfig } from '../../../../lib/schemas/io/global-config'; import { createTempConfig } from '../../../__tests__/helpers/temp-config'; -import { readGlobalConfig } from '../../../global-config'; import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from '../actions'; import { chmod, mkdir, rm, writeFile } from 'fs/promises'; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/src/cli/commands/telemetry/actions.ts b/src/cli/commands/telemetry/actions.ts index 90750a0f6..696608e01 100644 --- a/src/cli/commands/telemetry/actions.ts +++ b/src/cli/commands/telemetry/actions.ts @@ -1,4 +1,4 @@ -import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js'; +import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../../lib/schemas/io/global-config.js'; import { resolveTelemetryPreference } from '../../telemetry/config.js'; export async function handleTelemetryDisable( diff --git a/src/cli/commands/update/command.tsx b/src/cli/commands/update/command.tsx index cd7d3b70a..06bb9ebad 100644 --- a/src/cli/commands/update/command.tsx +++ b/src/cli/commands/update/command.tsx @@ -3,11 +3,54 @@ import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { handleUpdate } from './action'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; +import React from 'react'; export const registerUpdate = (program: Command) => { - program - .command('update') - .description(COMMAND_DESCRIPTIONS.update) + const updateCmd = program.command('update').description(COMMAND_DESCRIPTIONS.update); + + // Default action for bare `agentcore update` - backwards compatibility with CLI self-update + updateCmd.option('-c, --check', 'Check for updates without installing').action(async options => { + try { + render(Checking for updates...); + const result = await handleUpdate(options.check ?? false); + + switch (result.status) { + case 'up-to-date': + render(You are already on the latest version ({result.currentVersion})); + break; + case 'newer-local': + render( + + Your version ({result.currentVersion}) is newer than the published version ({result.latestVersion}) + + ); + break; + case 'update-available': + render( + + Update available: {result.currentVersion} → {result.latestVersion} + + ); + render(Run `agentcore update` to install the update.); + break; + case 'updated': + render(Successfully updated to {result.latestVersion}); + break; + case 'update-failed': + render(Failed to install update. Try running: npm install -g @aws/agentcore@latest); + process.exit(1); + break; + } + } catch (error) { + render(Error: {getErrorMessage(error)}); + process.exit(1); + } + }); + + // CLI self-update subcommand + updateCmd + .command('cli') + .description('Update the AgentCore CLI to the latest version') .option('-c, --check', 'Check for updates without installing') .action(async options => { try { @@ -31,7 +74,7 @@ export const registerUpdate = (program: Command) => { Update available: {result.currentVersion} → {result.latestVersion} ); - render(Run `agentcore update` to install the update.); + render(Run `agentcore update cli` to install the update.); break; case 'updated': render(Successfully updated to {result.latestVersion}); diff --git a/src/cli/external-requirements/__tests__/checks-extended.test.ts b/src/cli/external-requirements/__tests__/checks-extended.test.ts index 535d1b929..462d9be14 100644 --- a/src/cli/external-requirements/__tests__/checks-extended.test.ts +++ b/src/cli/external-requirements/__tests__/checks-extended.test.ts @@ -53,6 +53,9 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(requiresUv(project)).toBe(true); }); @@ -78,6 +81,9 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(requiresUv(project)).toBe(false); }); @@ -94,6 +100,9 @@ describe('requiresUv', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(requiresUv(project)).toBe(false); }); @@ -121,6 +130,9 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -146,6 +158,9 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -162,6 +177,9 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -195,6 +213,9 @@ describe('requiresContainerRuntime', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -262,6 +283,9 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const result = await checkDependencyVersions(project); @@ -282,6 +306,9 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const result = await checkDependencyVersions(project); @@ -310,6 +337,9 @@ describe('checkDependencyVersions', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const result = await checkDependencyVersions(project); diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index 9ec7e43ad..54f8aa0ba 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -17,7 +17,9 @@ export interface RemoveLoggerOptions { | 'evaluator' | 'online-eval' | 'policy-engine' - | 'policy'; + | 'policy' + | 'config-bundle' + | 'ab-test'; /** Name of the resource being removed */ resourceName: string; } diff --git a/src/cli/operations/ab-test/__tests__/promote.test.ts b/src/cli/operations/ab-test/__tests__/promote.test.ts new file mode 100644 index 000000000..2abf8583c --- /dev/null +++ b/src/cli/operations/ab-test/__tests__/promote.test.ts @@ -0,0 +1,270 @@ +import { promoteABTestConfig } from '../promote'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock ConfigIO — vi.hoisted ensures these are available before the hoisted vi.mock runs +const { mockReadProjectSpec, mockWriteProjectSpec, mockReadDeployedState } = vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockWriteProjectSpec: vi.fn(), + mockReadDeployedState: vi.fn(), +})); + +vi.mock('../../../../lib', () => { + class MockConfigIO { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + readDeployedState = mockReadDeployedState; + } + return { ConfigIO: MockConfigIO }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeConfigBundleProject(testName = 'myTest') { + return { + name: 'TestProject', + runtimes: [], + httpGateways: [], + onlineEvalConfigs: [], + abTests: [ + { + name: testName, + mode: 'config-bundle' as const, + gatewayRef: '{{gateway:my-gw}}', + variants: [ + { + name: 'C' as const, + weight: 50, + variantConfiguration: { + configurationBundle: { bundleArn: 'arn:aws:bundle:control', bundleVersion: 'v1' }, + }, + }, + { + name: 'T1' as const, + weight: 50, + variantConfiguration: { + configurationBundle: { bundleArn: 'arn:aws:bundle:treatment', bundleVersion: 'v2' }, + }, + }, + ], + evaluationConfig: { onlineEvaluationConfigArn: 'arn:aws:eval:config' }, + }, + ], + }; +} + +function makeTargetBasedProject(testName = 'targetTest') { + return { + name: 'TestProject', + runtimes: [ + { + name: 'my-runtime', + endpoints: { + control: { version: '1.0' }, + treatment: { version: '2.0' }, + }, + }, + ], + httpGateways: [ + { + name: 'my-gw', + targets: [ + { name: 'ctrl-target', runtimeRef: 'my-runtime', qualifier: 'control' }, + { name: 'treat-target', runtimeRef: 'my-runtime', qualifier: 'treatment' }, + ], + }, + ], + onlineEvalConfigs: [], + abTests: [ + { + name: testName, + mode: 'target-based' as const, + gatewayRef: '{{gateway:my-gw}}', + variants: [ + { + name: 'C' as const, + weight: 50, + variantConfiguration: { target: { targetName: 'ctrl-target' } }, + }, + { + name: 'T1' as const, + weight: 50, + variantConfiguration: { target: { targetName: 'treat-target' } }, + }, + ], + evaluationConfig: { + perVariantOnlineEvaluationConfig: [ + { treatmentName: 'C' as const, onlineEvaluationConfigArn: 'eval-c' }, + { treatmentName: 'T1' as const, onlineEvaluationConfigArn: 'eval-t1' }, + ], + }, + }, + ], + }; +} + +function makeDeployedState(specName: string, abTestId: string) { + return { + targets: { + default: { + resources: { + abTests: { + [specName]: { abTestId, abTestArn: `arn:aws:ab-test:${abTestId}` }, + }, + }, + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('promoteABTestConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockWriteProjectSpec.mockResolvedValue(undefined); + }); + + describe('target-based promote', () => { + it('updates control endpoint version to treatment version', async () => { + const project = makeTargetBasedProject(); + mockReadProjectSpec.mockResolvedValue(project); + mockReadDeployedState.mockResolvedValue(makeDeployedState('targetTest', 'ab-123')); + + const result = await promoteABTestConfig('ab-123'); + + expect(result.promoted).toBe(true); + expect(result.mode).toBe('target-based'); + expect(result.promotionDetail).toContain('control'); + expect(result.promotionDetail).toContain('2.0'); + + // Verify the project was written with updated control version + expect(mockWriteProjectSpec).toHaveBeenCalledOnce(); + const writtenProject = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenProject.runtimes[0].endpoints.control.version).toBe('2.0'); + }); + }); + + describe('config-bundle promote', () => { + it('copies treatment bundle ref to control', async () => { + const project = makeConfigBundleProject(); + mockReadProjectSpec.mockResolvedValue(project); + mockReadDeployedState.mockResolvedValue(makeDeployedState('myTest', 'ab-456')); + + const result = await promoteABTestConfig('ab-456'); + + expect(result.promoted).toBe(true); + expect(result.mode).toBe('config-bundle'); + expect(result.promotionDetail).toContain('arn:aws:bundle:treatment'); + expect(result.promotionDetail).toContain('v2'); + + // Verify the control bundle was updated + expect(mockWriteProjectSpec).toHaveBeenCalledOnce(); + const writtenProject = mockWriteProjectSpec.mock.calls[0]![0]; + const controlVariant = writtenProject.abTests[0].variants.find((v: { name: string }) => v.name === 'C'); + expect(controlVariant.variantConfiguration.configurationBundle.bundleArn).toBe('arn:aws:bundle:treatment'); + expect(controlVariant.variantConfiguration.configurationBundle.bundleVersion).toBe('v2'); + }); + }); + + describe('not found', () => { + it('returns promoted=false with message when AB test not found', async () => { + const project = makeConfigBundleProject(); + mockReadProjectSpec.mockResolvedValue(project); + mockReadDeployedState.mockResolvedValue({ targets: { default: { resources: { abTests: {} } } } }); + + const result = await promoteABTestConfig('nonexistent-id'); + + expect(result.promoted).toBe(false); + expect(result.promotionDetail).toContain('not found'); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + }); + + describe('ID-based lookup from deployed state', () => { + it('resolves spec name from deployed state using abTestId', async () => { + const project = makeConfigBundleProject('mySpecTest'); + mockReadProjectSpec.mockResolvedValue(project); + mockReadDeployedState.mockResolvedValue(makeDeployedState('mySpecTest', 'ab-789')); + + const result = await promoteABTestConfig('ab-789'); + + expect(result.promoted).toBe(true); + expect(result.mode).toBe('config-bundle'); + // Should have resolved without needing testNameFallback + expect(mockWriteProjectSpec).toHaveBeenCalledOnce(); + }); + + it('searches across multiple targets in deployed state', async () => { + const project = makeConfigBundleProject('crossTarget'); + mockReadProjectSpec.mockResolvedValue(project); + mockReadDeployedState.mockResolvedValue({ + targets: { + 'us-east-1': { resources: { abTests: {} } }, + 'us-west-2': { + resources: { + abTests: { + crossTarget: { abTestId: 'ab-cross', abTestArn: 'arn:aws:ab-test:ab-cross' }, + }, + }, + }, + }, + }); + + const result = await promoteABTestConfig('ab-cross'); + + expect(result.promoted).toBe(true); + }); + }); + + describe('name fallback when deployed state missing', () => { + it('falls back to name-based lookup when deployed state throws', async () => { + const project = makeConfigBundleProject('fallbackTest'); + mockReadProjectSpec.mockResolvedValue(project); + mockReadDeployedState.mockRejectedValue(new Error('No deployed state')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); + + const result = await promoteABTestConfig('unknown-id', 'fallbackTest'); + + expect(result.promoted).toBe(true); + expect(result.mode).toBe('config-bundle'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('falling back to name')); + + warnSpy.mockRestore(); + }); + + it('falls back to prefixed name match', async () => { + const project = makeConfigBundleProject('myTest'); + mockReadProjectSpec.mockResolvedValue(project); + mockReadDeployedState.mockRejectedValue(new Error('No deployed state')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); + + // testNameFallback uses the prefixed format {projectName}_{testName} + const result = await promoteABTestConfig('unknown-id', 'TestProject_myTest'); + + expect(result.promoted).toBe(true); + + warnSpy.mockRestore(); + }); + + it('returns not found when neither deployed state nor name matches', async () => { + const project = makeConfigBundleProject('myTest'); + mockReadProjectSpec.mockResolvedValue(project); + mockReadDeployedState.mockRejectedValue(new Error('No deployed state')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); + + const result = await promoteABTestConfig('unknown-id', 'nonexistent'); + + expect(result.promoted).toBe(false); + expect(result.promotionDetail).toContain('not found'); + + warnSpy.mockRestore(); + }); + }); +}); diff --git a/src/cli/operations/ab-test/promote.ts b/src/cli/operations/ab-test/promote.ts new file mode 100644 index 000000000..5f98e52f6 --- /dev/null +++ b/src/cli/operations/ab-test/promote.ts @@ -0,0 +1,124 @@ +import { ConfigIO } from '../../../lib'; + +export interface PromoteABTestResult { + promoted: boolean; + mode?: string; + promotionDetail: string; +} + +/** + * Resolve the spec-level AB test name from a deployed abTestId. + * Looks up which entry in deployed state has that abTestId and returns + * the spec name (the key in the abTests record). + */ +function resolveSpecNameFromDeployedState( + configIO: ConfigIO, + deployedState: { targets: Record } }> }, + abTestId: string +): string | undefined { + for (const target of Object.values(deployedState.targets)) { + const abTests = target.resources?.abTests; + if (!abTests) continue; + for (const [specName, entry] of Object.entries(abTests)) { + if (entry.abTestId === abTestId) { + return specName; + } + } + } + return undefined; +} + +/** + * Apply AB test promotion to agentcore.json. + * Updates the control variant's config to match the treatment variant. + * Does NOT stop the AB test — caller is responsible for that. + * + * @param abTestId - The deployed AB test ID + * @param testNameFallback - Optional name fallback when deployed state is unavailable + */ +export async function promoteABTestConfig(abTestId: string, testNameFallback?: string): Promise { + const configIO = new ConfigIO(); + const project = await configIO.readProjectSpec(); + + // Try to resolve spec name from deployed state + let specName: string | undefined; + try { + const deployedState = await configIO.readDeployedState(); + specName = resolveSpecNameFromDeployedState(configIO, deployedState, abTestId); + } catch { + // Deployed state unavailable + } + + // Fall back to name-based lookup if deployed state didn't resolve + if (!specName && testNameFallback) { + console.warn( + `[promote] Could not resolve AB test ID "${abTestId}" from deployed state; falling back to name "${testNameFallback}".` + ); + const lowerName = testNameFallback.toLowerCase(); + const match = (project.abTests ?? []).find( + t => t.name.toLowerCase() === lowerName || `${project.name}_${t.name}`.toLowerCase() === lowerName + ); + specName = match?.name; + } + + const abTest = specName ? (project.abTests ?? []).find(t => t.name === specName) : undefined; + + if (!abTest) { + return { promoted: false, promotionDetail: `AB test with ID "${abTestId}" not found in project config.` }; + } + + const mode = abTest.mode ?? 'config-bundle'; + + if (abTest.mode === 'target-based') { + const treatmentVariant = abTest.variants.find(v => v.name === 'T1'); + const controlVariant = abTest.variants.find(v => v.name === 'C'); + const controlTargetName = controlVariant?.variantConfiguration.target?.targetName; + const treatmentTargetName = treatmentVariant?.variantConfiguration.target?.targetName; + + const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(abTest.gatewayRef); + const gwName = gwMatch?.[1]; + if (gwName) { + const gw = (project.httpGateways ?? []).find(g => g.name === gwName); + if (gw?.targets) { + const controlTarget = gw.targets.find(t => t.name === controlTargetName); + const treatmentTarget = gw.targets.find(t => t.name === treatmentTargetName); + + if (controlTarget && treatmentTarget) { + const runtime = project.runtimes.find(r => r.name === controlTarget.runtimeRef); + const controlEp = runtime?.endpoints?.[controlTarget.qualifier]; + const treatmentEp = runtime?.endpoints?.[treatmentTarget.qualifier]; + if (controlEp && treatmentEp) { + controlEp.version = treatmentEp.version; + await configIO.writeProjectSpec(project); + return { + promoted: true, + mode, + promotionDetail: `Control endpoint "${controlTarget.qualifier}" updated to version ${treatmentEp.version} (from treatment "${treatmentTarget.qualifier}").`, + }; + } + } + } + } + return { promoted: false, mode, promotionDetail: 'Could not resolve target endpoints for promotion.' }; + } + + // Config-bundle mode + const controlVariant = abTest.variants.find(v => v.name === 'C'); + const treatmentVariant = abTest.variants.find(v => v.name === 'T1'); + if ( + controlVariant?.variantConfiguration.configurationBundle && + treatmentVariant?.variantConfiguration.configurationBundle + ) { + controlVariant.variantConfiguration.configurationBundle = { + ...treatmentVariant.variantConfiguration.configurationBundle, + }; + await configIO.writeProjectSpec(project); + return { + promoted: true, + mode, + promotionDetail: `Control bundle updated to "${treatmentVariant.variantConfiguration.configurationBundle.bundleArn}" version "${treatmentVariant.variantConfiguration.configurationBundle.bundleVersion}".`, + }; + } + + return { promoted: false, mode, promotionDetail: 'Could not resolve config bundles for promotion.' }; +} diff --git a/src/cli/operations/agent/config-bundle-defaults.ts b/src/cli/operations/agent/config-bundle-defaults.ts new file mode 100644 index 000000000..25db93003 --- /dev/null +++ b/src/cli/operations/agent/config-bundle-defaults.ts @@ -0,0 +1,30 @@ +import { ConfigIO } from '../../../lib'; + +export async function createConfigBundleForAgent(agentName: string, configBaseDir: string): Promise { + const configIO = new ConfigIO({ baseDir: configBaseDir }); + const project = await configIO.readProjectSpec(); + + const bundleName = `${agentName}Config`; + if ((project.configBundles ?? []).some(b => b.name === bundleName)) return; + + project.configBundles ??= []; + project.configBundles.push({ + type: 'ConfigurationBundle', + name: bundleName, + description: `Configuration for ${agentName} — managed by agentcore CLI`, + components: { + [`{{runtime:${agentName}}}`]: { + configuration: { + systemPrompt: 'You are a helpful assistant. Use tools when appropriate.', + toolDescriptions: { + add_numbers: 'Return the sum of two numbers', + }, + }, + }, + }, + branchName: 'mainline', + commitMessage: 'Initial configuration', + }); + + await configIO.writeProjectSpec(project); +} diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index 6d8ca15ab..3ed449236 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -284,5 +284,6 @@ export async function mapGenerateConfigToRenderConfig( dockerfile: config.dockerfile, sessionStorageMountPath: config.sessionStorageMountPath, enableOtel, + hasConfigBundle: config.withConfigBundle, }; } diff --git a/src/cli/operations/agent/generate/write-agent-to-project.ts b/src/cli/operations/agent/generate/write-agent-to-project.ts index 26750d279..bf48ddf9d 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -72,6 +72,9 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; await configIO.writeProjectSpec(project); diff --git a/src/cli/operations/archive/__tests__/archive-storage.test.ts b/src/cli/operations/archive/__tests__/archive-storage.test.ts new file mode 100644 index 000000000..9ebb41fd5 --- /dev/null +++ b/src/cli/operations/archive/__tests__/archive-storage.test.ts @@ -0,0 +1,130 @@ +import { deleteLocalBatchEvalRun, deleteLocalRecommendationRun } from '../archive-storage.js'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFindConfigRoot = vi.fn(); + +vi.mock('../../../../lib', () => ({ + findConfigRoot: () => mockFindConfigRoot(), +})); + +function makeTmpDir(): string { + const dir = join(tmpdir(), `archive-storage-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeJsonFile(path: string, data: unknown): void { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, JSON.stringify(data)); +} + +describe('archive-storage', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + mockFindConfigRoot.mockReturnValue(tmpDir); + }); + + afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + describe('deleteLocalBatchEvalRun', () => { + it('deletes the file and returns true when file exists', () => { + const filePath = join(tmpDir, '.cli', 'batch-eval-results', 'eval-123.json'); + writeJsonFile(filePath, { batchEvaluationId: 'eval-123' }); + + const result = deleteLocalBatchEvalRun('eval-123'); + + expect(result).toBe(true); + expect(existsSync(filePath)).toBe(false); + }); + + it('returns false when file does not exist', () => { + const result = deleteLocalBatchEvalRun('nonexistent-id'); + expect(result).toBe(false); + }); + + it('does not throw when the batch-eval-results directory does not exist', () => { + expect(() => deleteLocalBatchEvalRun('any-id')).not.toThrow(); + }); + + it('throws when findConfigRoot returns null', () => { + mockFindConfigRoot.mockReturnValue(null); + expect(() => deleteLocalBatchEvalRun('eval-123')).toThrow('No agentcore project found'); + }); + + it('throws when id contains a forward slash', () => { + expect(() => deleteLocalBatchEvalRun('../evil')).toThrow('Invalid batch evaluation ID'); + }); + + it('throws when id contains a backslash', () => { + expect(() => deleteLocalBatchEvalRun('evil\\path')).toThrow('Invalid batch evaluation ID'); + }); + + it('leaves other files in the directory untouched', () => { + const keep = join(tmpDir, '.cli', 'batch-eval-results', 'keep-me.json'); + const del = join(tmpDir, '.cli', 'batch-eval-results', 'delete-me.json'); + writeJsonFile(keep, { batchEvaluationId: 'keep-me' }); + writeJsonFile(del, { batchEvaluationId: 'delete-me' }); + + deleteLocalBatchEvalRun('delete-me'); + + expect(existsSync(keep)).toBe(true); + expect(existsSync(del)).toBe(false); + }); + }); + + describe('deleteLocalRecommendationRun', () => { + it('deletes the file and returns true when file exists', () => { + const filePath = join(tmpDir, '.cli', 'recommendations', 'rec-456.json'); + writeJsonFile(filePath, { recommendationId: 'rec-456' }); + + const result = deleteLocalRecommendationRun('rec-456'); + + expect(result).toBe(true); + expect(existsSync(filePath)).toBe(false); + }); + + it('returns false when file does not exist', () => { + const result = deleteLocalRecommendationRun('nonexistent-id'); + expect(result).toBe(false); + }); + + it('does not throw when the recommendations directory does not exist', () => { + expect(() => deleteLocalRecommendationRun('any-id')).not.toThrow(); + }); + + it('throws when findConfigRoot returns null', () => { + mockFindConfigRoot.mockReturnValue(null); + expect(() => deleteLocalRecommendationRun('rec-456')).toThrow('No agentcore project found'); + }); + + it('throws when id contains a forward slash', () => { + expect(() => deleteLocalRecommendationRun('../evil')).toThrow('Invalid recommendation ID'); + }); + + it('throws when id contains a backslash', () => { + expect(() => deleteLocalRecommendationRun('evil\\path')).toThrow('Invalid recommendation ID'); + }); + + it('leaves other files in the directory untouched', () => { + const keep = join(tmpDir, '.cli', 'recommendations', 'keep-me.json'); + const del = join(tmpDir, '.cli', 'recommendations', 'delete-me.json'); + writeJsonFile(keep, { recommendationId: 'keep-me' }); + writeJsonFile(del, { recommendationId: 'delete-me' }); + + deleteLocalRecommendationRun('delete-me'); + + expect(existsSync(keep)).toBe(true); + expect(existsSync(del)).toBe(false); + }); + }); +}); diff --git a/src/cli/operations/archive/archive-storage.ts b/src/cli/operations/archive/archive-storage.ts new file mode 100644 index 000000000..5b4481fda --- /dev/null +++ b/src/cli/operations/archive/archive-storage.ts @@ -0,0 +1,43 @@ +import { findConfigRoot } from '../../../lib'; +import { BATCH_EVAL_RESULTS_DIR } from '../eval/batch-eval-storage'; +import { RECOMMENDATIONS_DIR } from '../recommendation/recommendation-storage'; +import { existsSync, rmSync } from 'fs'; +import { join } from 'path'; + +function getCliDir(): string { + const configRoot = findConfigRoot(); + if (!configRoot) { + throw new Error('No agentcore project found. Run `agentcore create` first.'); + } + return join(configRoot, '.cli'); +} + +function assertSafeId(id: string, label: string): void { + if (/[/\\]/.test(id)) { + throw new Error(`Invalid ${label}: must not contain path separators`); + } +} + +/** + * Delete the local batch eval run record for the given ID. + * Returns true if the file existed and was deleted, false if it was not found. + */ +export function deleteLocalBatchEvalRun(batchEvaluationId: string): boolean { + assertSafeId(batchEvaluationId, 'batch evaluation ID'); + const filePath = join(getCliDir(), BATCH_EVAL_RESULTS_DIR, `${batchEvaluationId}.json`); + if (!existsSync(filePath)) return false; + rmSync(filePath); + return true; +} + +/** + * Delete the local recommendation run record for the given ID. + * Returns true if the file existed and was deleted, false if it was not found. + */ +export function deleteLocalRecommendationRun(recommendationId: string): boolean { + assertSafeId(recommendationId, 'recommendation ID'); + const filePath = join(getCliDir(), RECOMMENDATIONS_DIR, `${recommendationId}.json`); + if (!existsSync(filePath)) return false; + rmSync(filePath); + return true; +} diff --git a/src/cli/operations/archive/index.ts b/src/cli/operations/archive/index.ts new file mode 100644 index 000000000..0f5d523ba --- /dev/null +++ b/src/cli/operations/archive/index.ts @@ -0,0 +1 @@ +export { deleteLocalBatchEvalRun, deleteLocalRecommendationRun } from './archive-storage'; diff --git a/src/cli/operations/config-bundle/__tests__/bundle-name-variants.test.ts b/src/cli/operations/config-bundle/__tests__/bundle-name-variants.test.ts new file mode 100644 index 000000000..5c753cb52 --- /dev/null +++ b/src/cli/operations/config-bundle/__tests__/bundle-name-variants.test.ts @@ -0,0 +1,22 @@ +import { getBundleNameVariants } from '../bundle-name-variants'; +import { describe, expect, it } from 'vitest'; + +describe('getBundleNameVariants', () => { + it('returns only the bundle name when no project name', () => { + expect(getBundleNameVariants('MyBundle')).toEqual(['MyBundle']); + }); + + it('returns only the bundle name when project name is undefined', () => { + expect(getBundleNameVariants('MyBundle', undefined)).toEqual(['MyBundle']); + }); + + it('returns three variants when project name is provided', () => { + const variants = getBundleNameVariants('MyBundle', 'testevo'); + expect(variants).toEqual(['MyBundle', 'testevoMyBundle', 'testevo_MyBundle']); + }); + + it('filters out empty bundle name', () => { + const variants = getBundleNameVariants('', 'proj'); + expect(variants).toEqual(['proj', 'proj_']); + }); +}); diff --git a/src/cli/operations/config-bundle/__tests__/resolve-bundle.test.ts b/src/cli/operations/config-bundle/__tests__/resolve-bundle.test.ts new file mode 100644 index 000000000..6ecbeb71e --- /dev/null +++ b/src/cli/operations/config-bundle/__tests__/resolve-bundle.test.ts @@ -0,0 +1,103 @@ +import { resolveBundleByName } from '../resolve-bundle'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockListConfigurationBundles, mockListConfigurationBundleVersions } = vi.hoisted(() => ({ + mockListConfigurationBundles: vi.fn(), + mockListConfigurationBundleVersions: vi.fn(), +})); + +vi.mock('../../../aws/agentcore-config-bundles', () => ({ + listConfigurationBundles: mockListConfigurationBundles, + listConfigurationBundleVersions: mockListConfigurationBundleVersions, +})); + +const mockConfigIO = { + readDeployedState: vi.fn(), + readProjectSpec: vi.fn(), +} as any; + +const REGION = 'us-east-1'; + +describe('resolveBundleByName', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConfigIO.readDeployedState.mockResolvedValue({ targets: {} }); + mockConfigIO.readProjectSpec.mockResolvedValue({ name: 'testproj' }); + }); + + it('resolves via deployed state fast path', async () => { + mockConfigIO.readDeployedState.mockResolvedValue({ + targets: { + 'us-east-1': { + resources: { + configBundles: { + MyBundle: { bundleId: 'bundle-123', bundleArn: 'arn:bundle', versionId: 'v1' }, + }, + }, + }, + }, + }); + mockListConfigurationBundleVersions.mockResolvedValue({ + versions: [{ versionId: 'v2', versionCreatedAt: '2026-01-01T00:00:00Z' }], + }); + + const result = await resolveBundleByName('MyBundle', REGION, mockConfigIO); + expect(result.bundleId).toBe('bundle-123'); + expect(result.versionId).toBe('v2'); + expect(mockListConfigurationBundles).not.toHaveBeenCalled(); + }); + + it('falls back to API when deployed state is empty', async () => { + mockListConfigurationBundles.mockResolvedValue({ + bundles: [{ bundleId: 'bundle-456', bundleArn: 'arn:bundle-456', bundleName: 'testprojMyBundle' }], + nextToken: undefined, + }); + mockListConfigurationBundleVersions.mockResolvedValue({ + versions: [{ versionId: 'v1', versionCreatedAt: '2026-01-01T00:00:00Z' }], + }); + + const result = await resolveBundleByName('MyBundle', REGION, mockConfigIO); + expect(result.bundleId).toBe('bundle-456'); + }); + + it('matches legacy underscore-prefixed name', async () => { + mockListConfigurationBundles.mockResolvedValue({ + bundles: [{ bundleId: 'bundle-789', bundleArn: 'arn:bundle-789', bundleName: 'testproj_MyBundle' }], + nextToken: undefined, + }); + mockListConfigurationBundleVersions.mockResolvedValue({ + versions: [{ versionId: 'v1', versionCreatedAt: '2026-01-01T00:00:00Z' }], + }); + + const result = await resolveBundleByName('MyBundle', REGION, mockConfigIO); + expect(result.bundleId).toBe('bundle-789'); + }); + + it('paginates through multiple pages to find bundle', async () => { + mockListConfigurationBundles + .mockResolvedValueOnce({ + bundles: [{ bundleId: 'other-1', bundleArn: 'arn:other', bundleName: 'OtherBundle' }], + nextToken: 'page2', + }) + .mockResolvedValueOnce({ + bundles: [{ bundleId: 'bundle-found', bundleArn: 'arn:found', bundleName: 'testprojMyBundle' }], + nextToken: undefined, + }); + mockListConfigurationBundleVersions.mockResolvedValue({ + versions: [{ versionId: 'v1', versionCreatedAt: '2026-01-01T00:00:00Z' }], + }); + + const result = await resolveBundleByName('MyBundle', REGION, mockConfigIO); + expect(result.bundleId).toBe('bundle-found'); + expect(mockListConfigurationBundles).toHaveBeenCalledTimes(2); + }); + + it('throws when bundle not found after all pages', async () => { + mockListConfigurationBundles.mockResolvedValue({ + bundles: [{ bundleId: 'other', bundleArn: 'arn:other', bundleName: 'SomeOtherBundle' }], + nextToken: undefined, + }); + + await expect(resolveBundleByName('MyBundle', REGION, mockConfigIO)).rejects.toThrow('not found'); + }); +}); diff --git a/src/cli/operations/config-bundle/bundle-name-variants.ts b/src/cli/operations/config-bundle/bundle-name-variants.ts new file mode 100644 index 000000000..a282b9ad3 --- /dev/null +++ b/src/cli/operations/config-bundle/bundle-name-variants.ts @@ -0,0 +1,11 @@ +/** + * Returns all possible API-side names for a config bundle. + * The API stores bundles with a project-name prefix, but users reference them by local name. + */ +export function getBundleNameVariants(bundleName: string, projectName?: string): string[] { + return [ + bundleName, + projectName ? `${projectName}${bundleName}` : undefined, + projectName ? `${projectName}_${bundleName}` : undefined, + ].filter((x): x is string => Boolean(x)); +} diff --git a/src/cli/operations/config-bundle/diff-versions.ts b/src/cli/operations/config-bundle/diff-versions.ts new file mode 100644 index 000000000..cc9ae6ed9 --- /dev/null +++ b/src/cli/operations/config-bundle/diff-versions.ts @@ -0,0 +1,63 @@ +/** + * Client-side deep diff between two config bundle version components. + */ + +export interface DiffEntry { + path: string; + type: 'added' | 'removed' | 'changed'; + oldValue?: unknown; + newValue?: unknown; +} + +/** + * Deep diff two JSON objects, returning a flat list of changes with dot-notation paths. + */ +export function deepDiff(from: unknown, to: unknown, prefix = ''): DiffEntry[] { + const entries: DiffEntry[] = []; + + if (from === to) return entries; + + if (from === null || to === null || typeof from !== typeof to) { + if (from === undefined) { + entries.push({ path: prefix, type: 'added', newValue: to }); + } else if (to === undefined) { + entries.push({ path: prefix, type: 'removed', oldValue: from }); + } else { + entries.push({ path: prefix, type: 'changed', oldValue: from, newValue: to }); + } + return entries; + } + + if (typeof from !== 'object') { + entries.push({ path: prefix, type: 'changed', oldValue: from, newValue: to }); + return entries; + } + + if (Array.isArray(from) || Array.isArray(to)) { + if (!Array.isArray(from) || !Array.isArray(to) || from.length !== to.length) { + entries.push({ path: prefix, type: 'changed', oldValue: from, newValue: to }); + return entries; + } + for (let i = 0; i < from.length; i++) { + entries.push(...deepDiff(from[i], to[i], `${prefix}[${i}]`)); + } + return entries; + } + + const fromObj = from as Record; + const toObj = to as Record; + const allKeys = new Set([...Object.keys(fromObj), ...Object.keys(toObj)]); + + for (const key of allKeys) { + const childPath = prefix ? `${prefix}.${key}` : key; + if (!(key in fromObj)) { + entries.push({ path: childPath, type: 'added', newValue: toObj[key] }); + } else if (!(key in toObj)) { + entries.push({ path: childPath, type: 'removed', oldValue: fromObj[key] }); + } else { + entries.push(...deepDiff(fromObj[key], toObj[key], childPath)); + } + } + + return entries; +} diff --git a/src/cli/operations/config-bundle/resolve-bundle.ts b/src/cli/operations/config-bundle/resolve-bundle.ts new file mode 100644 index 000000000..964c705c1 --- /dev/null +++ b/src/cli/operations/config-bundle/resolve-bundle.ts @@ -0,0 +1,91 @@ +/** + * Resolves a config bundle name to its bundle ID. + * + * Fast path: reads deployed-state.json for known bundle IDs. + * Fallback: calls listConfigurationBundles API to find by name. + */ +import { ConfigIO } from '../../../lib'; +import { listConfigurationBundleVersions, listConfigurationBundles } from '../../aws/agentcore-config-bundles'; +import { getBundleNameVariants } from './bundle-name-variants'; + +export interface ResolvedBundle { + bundleId: string; + bundleArn?: string; + versionId?: string; + region: string; +} + +/** + * Resolve a bundle name to its API identifiers. + * Tries deployed-state.json first, then falls back to list API. + */ +export async function resolveBundleByName( + bundleName: string, + region: string, + configIO: ConfigIO = new ConfigIO() +): Promise { + // Fast path: check deployed state + const deployedState = await configIO.readDeployedState(); + for (const targetName of Object.keys(deployedState.targets ?? {})) { + const target = deployedState.targets?.[targetName]; + const bundles = target?.resources?.configBundles; + const bundle = bundles?.[bundleName]; + if (bundle) { + // Verify the bundle still exists by listing versions (branch-agnostic) + try { + const versions = await listConfigurationBundleVersions({ + region, + bundleId: bundle.bundleId, + maxResults: 1, + }); + const latestVersion = versions.versions[0]; + return { + bundleId: bundle.bundleId, + bundleArn: bundle.bundleArn, + versionId: latestVersion?.versionId ?? bundle.versionId, + region, + }; + } catch { + // Stale deployed-state entry — fall through to API lookup + } + } + } + + // Fallback: search via API + // The API stores bundles with a prefixed name: {projectName}{bundleName} + let projectName: string | undefined; + try { + const projectSpec = await configIO.readProjectSpec(); + projectName = projectSpec.name; + } catch { + // Project spec may not be available + } + + const nameVariants = getBundleNameVariants(bundleName, projectName); + let nextToken: string | undefined; + let match: { bundleId: string; bundleArn: string; bundleName: string } | undefined; + do { + const page = await listConfigurationBundles({ region, maxResults: 100, nextToken }); + match = page.bundles.find(b => nameVariants.includes(b.bundleName)); + nextToken = page.nextToken; + } while (!match && nextToken); + + if (!match) { + throw new Error(`Configuration bundle "${bundleName}" not found. Has it been deployed?`); + } + + // Get the latest version ID (branch-agnostic) + const versions = await listConfigurationBundleVersions({ + region, + bundleId: match.bundleId, + maxResults: 1, + }); + const latestVersion = versions.versions[0]; + + return { + bundleId: match.bundleId, + bundleArn: match.bundleArn, + versionId: latestVersion?.versionId, + region, + }; +} diff --git a/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts new file mode 100644 index 000000000..39a32d20f --- /dev/null +++ b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts @@ -0,0 +1,596 @@ +import type { AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema'; +import { deleteOrphanedABTests, setupABTests } from '../post-deploy-ab-tests.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Hoisted mocks ────────────────────────────────────────────────────────── + +const { + mockCreateABTest, + mockDeleteABTest, + mockGetABTest, + mockUpdateABTest, + mockListABTests, + mockGetCredentialProvider, + mockIAMSend, +} = vi.hoisted(() => ({ + mockCreateABTest: vi.fn(), + mockDeleteABTest: vi.fn(), + mockGetABTest: vi.fn(), + mockUpdateABTest: vi.fn(), + mockListABTests: vi.fn(), + mockGetCredentialProvider: vi.fn().mockReturnValue(undefined), + mockIAMSend: vi.fn(), +})); + +vi.mock('../../../aws/agentcore-ab-tests', () => ({ + createABTest: mockCreateABTest, + deleteABTest: mockDeleteABTest, + getABTest: mockGetABTest, + updateABTest: mockUpdateABTest, + listABTests: mockListABTests, +})); + +vi.mock('../../../aws/account', () => ({ + getCredentialProvider: mockGetCredentialProvider, +})); + +vi.mock('@aws-sdk/client-iam', () => ({ + IAMClient: class { + send = mockIAMSend; + }, + CreateRoleCommand: class { + constructor(public input: unknown) {} + }, + PutRolePolicyCommand: class { + constructor(public input: unknown) {} + }, + DeleteRolePolicyCommand: class { + constructor(public input: unknown) {} + }, + DeleteRoleCommand: class { + constructor(public input: unknown) {} + }, +})); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function makeProjectSpec(abTests: AgentCoreProjectSpec['abTests'] = []): AgentCoreProjectSpec { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + httpGateways: [], + abTests, + }; +} + +const sampleABTest = { + name: 'TestOne', + mode: 'config-bundle' as const, + gatewayRef: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-123', + variants: [ + { + name: 'C' as const, + weight: 80, + variantConfiguration: { configurationBundle: { bundleArn: 'arn:bundle:control', bundleVersion: 'v1' } }, + }, + { + name: 'T1' as const, + weight: 20, + variantConfiguration: { configurationBundle: { bundleArn: 'arn:bundle:treatment', bundleVersion: 'v1' } }, + }, + ], + evaluationConfig: { onlineEvaluationConfigArn: 'arn:eval:config' }, + roleArn: 'arn:aws:iam::123456789012:role/ExistingRole', +}; + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('setupABTests', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListABTests.mockResolvedValue({ abTests: [] }); + mockUpdateABTest.mockResolvedValue({}); + mockGetABTest.mockResolvedValue({ status: 'ACTIVE', executionStatus: 'STOPPED' }); + }); + + describe('creation', () => { + it('creates new AB test when not in deployed state', async () => { + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-001', abTestArn: 'arn:abt:001' }); + + const result = await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([sampleABTest]), + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('created'); + expect(result.results[0]!.abTestId).toBe('abt-001'); + expect(result.abTests.TestOne).toEqual( + expect.objectContaining({ abTestId: 'abt-001', abTestArn: 'arn:abt:001' }) + ); + }); + + it('updates already-deployed test', async () => { + mockUpdateABTest.mockResolvedValue({ abTestId: 'abt-existing', abTestArn: 'arn:abt:existing' }); + + const result = await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([sampleABTest]), + existingABTests: { + TestOne: { abTestId: 'abt-existing', abTestArn: 'arn:abt:existing' }, + }, + }); + + expect(result.results[0]!.status).toBe('updated'); + expect(mockCreateABTest).not.toHaveBeenCalled(); + expect(mockUpdateABTest).toHaveBeenCalled(); + }); + + it('updates test found via API list (state loss recovery)', async () => { + mockListABTests.mockResolvedValue({ + abTests: [{ name: 'TestOne', abTestId: 'abt-api', abTestArn: 'arn:abt:api' }], + }); + mockUpdateABTest.mockResolvedValue({ abTestId: 'abt-api', abTestArn: 'arn:abt:api' }); + + const result = await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([sampleABTest]), + }); + + expect(result.results[0]!.status).toBe('updated'); + expect(result.abTests.TestOne!.abTestId).toBe('abt-api'); + expect(mockCreateABTest).not.toHaveBeenCalled(); + expect(mockUpdateABTest).toHaveBeenCalled(); + }); + + it('auto-creates IAM role when roleArn not provided', async () => { + const testWithoutRole = { ...sampleABTest, roleArn: undefined }; + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-002', abTestArn: 'arn:abt:002' }); + mockIAMSend.mockResolvedValue({ Role: { Arn: 'arn:aws:iam::123:role/AutoRole' } }); + + const result = await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([testWithoutRole]), + }); + + expect(result.results[0]!.status).toBe('created'); + expect(result.abTests.TestOne!.roleCreatedByCli).toBe(true); + expect(mockIAMSend).toHaveBeenCalled(); + }); + + it('uses provided roleArn without creating IAM role', async () => { + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-003', abTestArn: 'arn:abt:003' }); + + const result = await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([sampleABTest]), + }); + + expect(result.results[0]!.status).toBe('created'); + expect(result.abTests.TestOne!.roleCreatedByCli).toBe(false); + expect(mockIAMSend).not.toHaveBeenCalled(); + }); + + it('reports error when createABTest fails', async () => { + mockCreateABTest.mockRejectedValue(new Error('API failure')); + + const result = await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([sampleABTest]), + }); + + expect(result.hasErrors).toBe(true); + expect(result.results[0]!.status).toBe('error'); + expect(result.results[0]!.error).toBe('API failure'); + }); + }); + + describe('ARN resolution', () => { + it('resolves bundle name to ARN from deployed state', async () => { + const testWithNames = { + ...sampleABTest, + variants: [ + { + name: 'C' as const, + weight: 80, + variantConfiguration: { configurationBundle: { bundleArn: 'my-bundle', bundleVersion: 'LATEST' } }, + }, + { + name: 'T1' as const, + weight: 20, + variantConfiguration: { configurationBundle: { bundleArn: 'my-bundle', bundleVersion: 'v2' } }, + }, + ], + }; + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-004', abTestArn: 'arn:abt:004' }); + + await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([testWithNames]), + deployedResources: { + configBundles: { + 'my-bundle': { bundleArn: 'arn:bundle:resolved', versionId: 'ver-latest' }, + }, + } as unknown as DeployedResourceState, + }); + + const callArgs = mockCreateABTest.mock.calls[0]![0]; + expect(callArgs.variants[0].variantConfiguration.configurationBundle.bundleArn).toBe('arn:bundle:resolved'); + expect(callArgs.variants[0].variantConfiguration.configurationBundle.bundleVersion).toBe('ver-latest'); + expect(callArgs.variants[1].variantConfiguration.configurationBundle.bundleVersion).toBe('v2'); + }); + + it('resolves gateway placeholder to ARN', async () => { + const testWithPlaceholder = { + ...sampleABTest, + gatewayRef: '{{gateway:my-gw}}', + }; + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-005', abTestArn: 'arn:abt:005' }); + + await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([testWithPlaceholder]), + deployedResources: { + mcp: { + gateways: { + 'my-gw': { gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/resolved-gw' }, + }, + }, + } as unknown as DeployedResourceState, + }); + + expect(mockCreateABTest.mock.calls[0]![0].gatewayArn).toBe( + 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/resolved-gw' + ); + }); + + it('resolves gateway placeholder to ARN from HTTP gateways', async () => { + const testWithPlaceholder = { + ...sampleABTest, + gatewayRef: '{{gateway:my-http-gw}}', + }; + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-007', abTestArn: 'arn:abt:007' }); + + await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([testWithPlaceholder]), + deployedResources: { + httpGateways: { + 'my-http-gw': { + gatewayId: 'httpgw-001', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:httpgateway/httpgw-001', + }, + }, + } as unknown as DeployedResourceState, + }); + + expect(mockCreateABTest.mock.calls[0]![0].gatewayArn).toBe( + 'arn:aws:bedrock-agentcore:us-east-1:123:httpgateway/httpgw-001' + ); + }); + + it('resolves online eval config name to ARN', async () => { + const testWithEvalName = { + ...sampleABTest, + evaluationConfig: { onlineEvaluationConfigArn: 'my-eval-config' }, + }; + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-006', abTestArn: 'arn:abt:006' }); + + await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([testWithEvalName]), + deployedResources: { + onlineEvalConfigs: { + 'my-eval-config': { onlineEvaluationConfigArn: 'arn:eval:resolved' }, + }, + } as unknown as DeployedResourceState, + }); + + expect(mockCreateABTest.mock.calls[0]![0].evaluationConfig.onlineEvaluationConfigArn).toBe('arn:eval:resolved'); + }); + }); + + describe('deletion (reconciliation)', () => { + it('stops, polls until executionStatus is STOPPED, then deletes orphaned AB test', async () => { + const callOrder: string[] = []; + mockUpdateABTest.mockImplementation(() => { + callOrder.push('stop'); + return Promise.resolve({}); + }); + let getCallCount = 0; + mockGetABTest.mockImplementation(() => { + getCallCount++; + callOrder.push(`poll(${getCallCount})`); + // First poll: executionStatus not yet STOPPED (still transitioning) + if (getCallCount === 1) return Promise.resolve({ status: 'ACTIVE', executionStatus: 'RUNNING' }); + // Second poll: executionStatus is STOPPED — done + return Promise.resolve({ status: 'ACTIVE', executionStatus: 'STOPPED' }); + }); + mockDeleteABTest.mockImplementation(() => { + callOrder.push('delete'); + return Promise.resolve({ success: true }); + }); + + const result = await deleteOrphanedABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingABTests: { + RemovedTest: { abTestId: 'abt-old', abTestArn: 'arn:abt:old' }, + }, + }); + + // Verify: stop → poll (RUNNING) → poll (STOPPED) → delete + expect(callOrder).toEqual(['stop', 'poll(1)', 'poll(2)', 'delete']); + expect(mockUpdateABTest).toHaveBeenCalledWith({ + region: 'us-east-1', + abTestId: 'abt-old', + executionStatus: 'STOPPED', + }); + expect(result.results[0]!.status).toBe('deleted'); + }); + + it('proceeds with delete when stop fails (already stopped)', async () => { + mockUpdateABTest.mockRejectedValue(new Error('Cannot update in current state')); + mockDeleteABTest.mockResolvedValue({ success: true }); + + const result = await deleteOrphanedABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingABTests: { + RemovedTest: { abTestId: 'abt-stopped', abTestArn: 'arn:abt:stopped' }, + }, + }); + + expect(mockUpdateABTest).toHaveBeenCalled(); + expect(mockDeleteABTest).toHaveBeenCalled(); + expect(result.results[0]!.status).toBe('deleted'); + }); + + it('cleans up auto-created IAM role on deletion', async () => { + mockDeleteABTest.mockResolvedValue({ success: true }); + mockIAMSend.mockResolvedValue({}); + + await deleteOrphanedABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingABTests: { + RemovedTest: { + abTestId: 'abt-old', + abTestArn: 'arn:abt:old', + roleArn: 'arn:aws:iam::123:role/AutoCreatedRole', + roleCreatedByCli: true, + }, + }, + }); + + // Should have called delete policy + delete role + expect(mockIAMSend).toHaveBeenCalledTimes(2); + + // Verify first call is DeleteRolePolicyCommand + const firstCall = mockIAMSend.mock.calls[0]![0]; + expect(firstCall.input).toEqual( + expect.objectContaining({ RoleName: 'AutoCreatedRole', PolicyName: expect.any(String) }) + ); + + // Verify second call is DeleteRoleCommand + const secondCall = mockIAMSend.mock.calls[1]![0]; + expect(secondCall.input).toEqual(expect.objectContaining({ RoleName: 'AutoCreatedRole' })); + }); + + it('does not delete role when roleCreatedByCli is false', async () => { + mockDeleteABTest.mockResolvedValue({ success: true }); + + await deleteOrphanedABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingABTests: { + RemovedTest: { + abTestId: 'abt-old', + abTestArn: 'arn:abt:old', + roleArn: 'arn:aws:iam::123:role/UserRole', + roleCreatedByCli: false, + }, + }, + }); + + expect(mockIAMSend).not.toHaveBeenCalled(); + }); + + it('reports error when deletion fails', async () => { + mockDeleteABTest.mockRejectedValue(new Error('delete failed')); + + const result = await deleteOrphanedABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingABTests: { + FailTest: { abTestId: 'abt-fail', abTestArn: 'arn:abt:fail' }, + }, + }); + + expect(result.hasErrors).toBe(true); + expect(result.results[0]!.status).toBe('error'); + expect(result.results[0]!.error).toBe('delete failed'); + }); + + it('sets warning when AB test was stopped before deletion', async () => { + mockUpdateABTest.mockResolvedValue({}); + mockGetABTest.mockResolvedValue({ status: 'ACTIVE', executionStatus: 'STOPPED' }); + mockDeleteABTest.mockResolvedValue({ success: true }); + + const result = await deleteOrphanedABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingABTests: { + StoppedTest: { abTestId: 'abt-warn', abTestArn: 'arn:abt:warn' }, + }, + }); + + expect(result.results[0]!.status).toBe('deleted'); + expect(result.results[0]!.warning).toBe('AB test "StoppedTest" was stopped before deletion'); + }); + + it('does not set warning when stop fails (already stopped)', async () => { + mockUpdateABTest.mockRejectedValue(new Error('Cannot update')); + mockDeleteABTest.mockResolvedValue({ success: true }); + + const result = await deleteOrphanedABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingABTests: { + AlreadyStopped: { abTestId: 'abt-no-warn', abTestArn: 'arn:abt:no-warn' }, + }, + }); + + expect(result.results[0]!.status).toBe('deleted'); + expect(result.results[0]!.warning).toBeUndefined(); + }); + + it('proceeds with delete even when poll never reaches STOPPED (timeout)', async () => { + mockUpdateABTest.mockResolvedValue({}); + // executionStatus never becomes STOPPED — always RUNNING + mockGetABTest.mockResolvedValue({ status: 'ACTIVE', executionStatus: 'RUNNING' }); + mockDeleteABTest.mockResolvedValue({ success: true }); + + const result = await deleteOrphanedABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingABTests: { + StuckTest: { abTestId: 'abt-stuck', abTestArn: 'arn:abt:stuck' }, + }, + }); + + // Should still attempt delete after exhausting poll loop + expect(mockDeleteABTest).toHaveBeenCalledWith({ region: 'us-east-1', abTestId: 'abt-stuck' }); + expect(result.results[0]!.status).toBe('deleted'); + // Poll was called 20 times (the loop limit) + expect(mockGetABTest).toHaveBeenCalledTimes(20); + // Should warn that polling timed out + expect(result.results[0]!.warning).toBe( + 'AB test "StuckTest" did not reach STOPPED status within the polling window — proceeding with delete' + ); + }, 120_000); + + it('sets warning even when deleteABTest returns success: false', async () => { + mockUpdateABTest.mockResolvedValue({}); + mockGetABTest.mockResolvedValue({ status: 'ACTIVE', executionStatus: 'STOPPED' }); + mockDeleteABTest.mockResolvedValue({ success: false, error: 'still running' }); + + const result = await deleteOrphanedABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingABTests: { + FailAfterStop: { abTestId: 'abt-fail-stop', abTestArn: 'arn:abt:fail-stop' }, + }, + }); + + expect(result.results[0]!.status).toBe('error'); + expect(result.results[0]!.error).toBe('still running'); + // Warning should still be set because stop succeeded + expect(result.results[0]!.warning).toBe('AB test "FailAfterStop" was stopped before deletion'); + }); + }); + + describe('IAM role creation', () => { + it('creates role with correct trust policy and inline policy', async () => { + const testWithoutRole = { ...sampleABTest, roleArn: undefined }; + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-iam', abTestArn: 'arn:abt:iam' }); + mockIAMSend.mockResolvedValue({ Role: { Arn: 'arn:aws:iam::123:role/AutoRole' } }); + + await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([testWithoutRole]), + }); + + // First call: CreateRoleCommand with trust policy + const createRoleCall = mockIAMSend.mock.calls[0]![0]; + const trustPolicy = JSON.parse(createRoleCall.input.AssumeRolePolicyDocument); + expect(trustPolicy.Statement).toHaveLength(1); + expect(trustPolicy.Statement[0].Principal.Service).toBe('bedrock-agentcore.amazonaws.com'); + expect(trustPolicy.Statement[0].Condition.StringEquals['aws:SourceAccount']).toBeDefined(); + expect(trustPolicy.Statement[0].Condition.ArnLike['aws:SourceArn']).toContain('ab-test/*'); + + // Second call: PutRolePolicyCommand with inline policy + const putPolicyCall = mockIAMSend.mock.calls[1]![0]; + const policy = JSON.parse(putPolicyCall.input.PolicyDocument); + const sids = policy.Statement.map((s: { Sid: string }) => s.Sid); + expect(sids).toContain('AgentCoreResources'); + expect(sids).toContain('CloudWatchLogs'); + + // AgentCoreResources must include all required actions + const agentCoreStmt = policy.Statement.find((s: { Sid: string }) => s.Sid === 'AgentCoreResources'); + expect(agentCoreStmt.Action).toContain('bedrock-agentcore:GetEvaluator'); + expect(agentCoreStmt.Action).toContain('bedrock-agentcore:GetGateway'); + expect(agentCoreStmt.Action).toContain('bedrock-agentcore:GetOnlineEvaluationConfig'); + expect(agentCoreStmt.Condition.StringEquals['aws:ResourceAccount']).toBeDefined(); + }); + }); + + describe('edge cases', () => { + it('proceeds with creation when listABTests fails', async () => { + mockListABTests.mockRejectedValue(new Error('API unavailable')); + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-new', abTestArn: 'arn:abt:new' }); + + const result = await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([sampleABTest]), + }); + + expect(result.results[0]!.status).toBe('created'); + expect(mockCreateABTest).toHaveBeenCalled(); + }); + + it('swallows errors during IAM role deletion', async () => { + mockDeleteABTest.mockResolvedValue({ success: true }); + mockIAMSend.mockRejectedValue(new Error('IAM permission denied')); + + const result = await deleteOrphanedABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingABTests: { + OldTest: { + abTestId: 'abt-old', + abTestArn: 'arn:abt:old', + roleArn: 'arn:aws:iam::123:role/SomeRole', + roleCreatedByCli: true, + }, + }, + }); + + // Deletion should still succeed even though IAM cleanup failed + expect(result.results[0]!.status).toBe('deleted'); + }); + }); + + describe('mixed operations', () => { + it('creates new and updates existing', async () => { + const newTest = { ...sampleABTest, name: 'NewTest' }; + const keptTest = { ...sampleABTest, name: 'KeptTest' }; + + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-new', abTestArn: 'arn:abt:new' }); + mockUpdateABTest.mockResolvedValue({ abTestId: 'abt-kept', abTestArn: 'arn:abt:kept' }); + + const result = await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([newTest, keptTest]), + existingABTests: { + KeptTest: { abTestId: 'abt-kept', abTestArn: 'arn:abt:kept' }, + }, + }); + + expect(result.results).toHaveLength(2); + const statuses = result.results.map(r => `${r.testName}:${r.status}`); + expect(statuses).toContain('NewTest:created'); + expect(statuses).toContain('KeptTest:updated'); + }); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts new file mode 100644 index 000000000..ecfc285cd --- /dev/null +++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts @@ -0,0 +1,651 @@ +import type { AgentCoreProjectSpec, DeployedState } from '../../../../schema'; +import { resolveConfigBundleComponentKeys, setupConfigBundles } from '../post-deploy-config-bundles.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockCreateConfigurationBundle, + mockDeleteConfigurationBundle, + mockGetConfigurationBundleVersion, + mockListConfigurationBundleVersions, + mockListConfigurationBundles, + mockUpdateConfigurationBundle, +} = vi.hoisted(() => ({ + mockCreateConfigurationBundle: vi.fn(), + mockDeleteConfigurationBundle: vi.fn(), + mockGetConfigurationBundleVersion: vi.fn(), + mockListConfigurationBundleVersions: vi.fn(), + mockListConfigurationBundles: vi.fn(), + mockUpdateConfigurationBundle: vi.fn(), +})); + +vi.mock('../../../aws/agentcore-config-bundles', () => ({ + createConfigurationBundle: mockCreateConfigurationBundle, + deleteConfigurationBundle: mockDeleteConfigurationBundle, + getConfigurationBundleVersion: mockGetConfigurationBundleVersion, + listConfigurationBundleVersions: mockListConfigurationBundleVersions, + listConfigurationBundles: mockListConfigurationBundles, + updateConfigurationBundle: mockUpdateConfigurationBundle, +})); + +const REGION = 'us-west-2'; + +function makeProjectSpec(configBundles: Record[]) { + return { name: 'TestProject', configBundles } as any; +} + +describe('setupConfigBundles', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('create new bundle', () => { + it('should create a new bundle when not in existingBundles and not found by name', async () => { + mockListConfigurationBundles.mockResolvedValue({ bundles: [] }); + mockCreateConfigurationBundle.mockResolvedValue({ + bundleId: 'b-new', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-new', + versionId: 'v-1', + }); + + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([ + { name: 'MyBundle', type: 'ConfigurationBundle', components: { foo: { type: 'inline', value: 'bar' } } }, + ]), + }); + + expect(mockCreateConfigurationBundle).toHaveBeenCalledWith( + expect.objectContaining({ + region: REGION, + bundleName: 'TestProjectMyBundle', + components: { foo: { type: 'inline', value: 'bar' } }, + commitMessage: 'Create MyBundle', + }) + ); + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]).toMatchObject({ bundleName: 'MyBundle', status: 'created', bundleId: 'b-new' }); + expect(result.configBundles.MyBundle).toEqual({ + bundleId: 'b-new', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-new', + versionId: 'v-1', + }); + }); + }); + + describe('update existing bundle', () => { + it('should update an existing bundle when components have changed', async () => { + const existingBundles = { + MyBundle: { + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-1', + }, + }; + + mockGetConfigurationBundleVersion.mockResolvedValue({ + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-1', + components: { foo: { type: 'inline', value: 'old' } }, + description: undefined, + lineageMetadata: { branchName: 'main' }, + }); + + mockUpdateConfigurationBundle.mockResolvedValue({ + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-2', + }); + + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([ + { name: 'MyBundle', type: 'ConfigurationBundle', components: { foo: { type: 'inline', value: 'new' } } }, + ]), + existingBundles, + }); + + expect(mockUpdateConfigurationBundle).toHaveBeenCalledWith( + expect.objectContaining({ + region: REGION, + bundleId: 'b-123', + components: { foo: { type: 'inline', value: 'new' } }, + parentVersionIds: ['v-1'], + branchName: 'main', + commitMessage: 'Update MyBundle', + }) + ); + expect(result.results[0]).toMatchObject({ status: 'updated', versionId: 'v-2' }); + expect(result.hasErrors).toBe(false); + }); + }); + + describe('skip unchanged bundle', () => { + it('should skip update when components and description are unchanged', async () => { + const components = { foo: { type: 'inline', value: 'same' } }; + const existingBundles = { + MyBundle: { + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-1', + }, + }; + + mockGetConfigurationBundleVersion.mockResolvedValue({ + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-1', + components, + description: 'My desc', + lineageMetadata: { branchName: 'main' }, + }); + + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([ + { name: 'MyBundle', type: 'ConfigurationBundle', components, description: 'My desc' }, + ]), + existingBundles, + }); + + expect(mockUpdateConfigurationBundle).not.toHaveBeenCalled(); + expect(mockCreateConfigurationBundle).not.toHaveBeenCalled(); + expect(result.results[0]).toMatchObject({ bundleName: 'MyBundle', status: 'skipped', versionId: 'v-1' }); + expect(result.configBundles.MyBundle).toEqual(existingBundles.MyBundle); + }); + }); + + describe('deep equal is key-order-independent', () => { + it('should skip update when components differ only in key order', async () => { + const existingBundles = { + MyBundle: { + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-1', + }, + }; + + // API returns keys in one order + mockGetConfigurationBundleVersion.mockResolvedValue({ + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-1', + components: { a: { type: 'inline', value: '1' }, b: { type: 'inline', value: '2' } }, + description: undefined, + lineageMetadata: { branchName: 'main' }, + }); + + // Spec has same keys in different order + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([ + { + name: 'MyBundle', + components: { b: { type: 'inline', value: '2' }, a: { type: 'inline', value: '1' } }, + }, + ]), + existingBundles, + }); + + expect(mockUpdateConfigurationBundle).not.toHaveBeenCalled(); + expect(result.results[0]).toMatchObject({ status: 'skipped' }); + }); + }); + + describe('delete orphaned bundles', () => { + it('should delete bundles in existingBundles but not in projectSpec', async () => { + const existingBundles = { + OrphanBundle: { + bundleId: 'b-orphan', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-orphan', + versionId: 'v-1', + }, + }; + + mockDeleteConfigurationBundle.mockResolvedValue(undefined); + + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([]), + existingBundles, + }); + + expect(mockDeleteConfigurationBundle).toHaveBeenCalledWith({ + region: REGION, + bundleId: 'b-orphan', + }); + expect(result.results[0]).toMatchObject({ bundleName: 'OrphanBundle', status: 'deleted' }); + expect(result.hasErrors).toBe(false); + }); + + it('should report error status when delete throws', async () => { + const existingBundles = { + OrphanBundle: { + bundleId: 'b-orphan', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-orphan', + versionId: 'v-1', + }, + }; + + mockDeleteConfigurationBundle.mockRejectedValue(new Error('Access denied')); + + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([]), + existingBundles, + }); + + expect(result.results[0]).toMatchObject({ bundleName: 'OrphanBundle', status: 'error', error: 'Access denied' }); + expect(result.hasErrors).toBe(true); + }); + }); + + describe('uses branch from API when bundleSpec has no branchName', () => { + it('should use branchName from getConfigurationBundleVersion lineageMetadata', async () => { + const existingBundles = { + MyBundle: { + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-1', + }, + }; + + mockGetConfigurationBundleVersion.mockResolvedValue({ + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-1', + components: { old: { type: 'inline', value: 'data' } }, + description: undefined, + lineageMetadata: { branchName: 'feature-branch' }, + }); + + mockUpdateConfigurationBundle.mockResolvedValue({ + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-2', + }); + + await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([ + { + name: 'MyBundle', + components: { new: { type: 'inline', value: 'data' } }, + // no branchName specified + }, + ]), + existingBundles, + }); + + expect(mockUpdateConfigurationBundle).toHaveBeenCalledWith( + expect.objectContaining({ + branchName: 'feature-branch', + }) + ); + }); + + it('should prefer bundleSpec branchName over API branchName', async () => { + const existingBundles = { + MyBundle: { + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-1', + }, + }; + + mockGetConfigurationBundleVersion.mockResolvedValue({ + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-1', + components: { old: { type: 'inline', value: 'data' } }, + description: undefined, + lineageMetadata: { branchName: 'api-branch' }, + }); + + mockUpdateConfigurationBundle.mockResolvedValue({ + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-2', + }); + + await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([ + { + name: 'MyBundle', + components: { new: { type: 'inline', value: 'data' } }, + branchName: 'spec-branch', + }, + ]), + existingBundles, + }); + + expect(mockUpdateConfigurationBundle).toHaveBeenCalledWith( + expect.objectContaining({ + branchName: 'spec-branch', + }) + ); + }); + }); + + describe('fallback path via findBundleByName', () => { + it('should fall through to findBundleByName when getConfigurationBundleVersion throws 404', async () => { + const existingBundles = { + MyBundle: { + bundleId: 'b-old', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-old', + versionId: 'v-old', + }, + }; + + // First call (existing bundle path) throws 404 + mockGetConfigurationBundleVersion.mockRejectedValueOnce(new Error('404 not found')).mockResolvedValueOnce({ + bundleId: 'b-found', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-found', + versionId: 'v-latest', + components: { old: { type: 'inline', value: 'data' } }, + description: undefined, + lineageMetadata: { branchName: 'main' }, + }); + + mockListConfigurationBundles.mockResolvedValue({ + bundles: [{ bundleId: 'b-found', bundleName: 'TestProjectMyBundle' }], + }); + + mockListConfigurationBundleVersions.mockResolvedValue({ + versions: [{ versionId: 'v-latest', versionCreatedAt: 1234567890 }], + }); + + mockUpdateConfigurationBundle.mockResolvedValue({ + bundleId: 'b-found', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-found', + versionId: 'v-new', + }); + + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([ + { + name: 'MyBundle', + components: { new: { type: 'inline', value: 'data' } }, + }, + ]), + existingBundles, + }); + + expect(mockListConfigurationBundles).toHaveBeenCalledWith({ region: REGION, maxResults: 100 }); + expect(mockListConfigurationBundleVersions).toHaveBeenCalledWith({ + region: REGION, + bundleId: 'b-found', + }); + expect(result.results[0]).toMatchObject({ status: 'updated', bundleId: 'b-found', versionId: 'v-new' }); + expect(result.hasErrors).toBe(false); + }); + + it('should create a new bundle when findBundleByName returns nothing after 404', async () => { + const existingBundles = { + MyBundle: { + bundleId: 'b-old', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-old', + versionId: 'v-old', + }, + }; + + mockGetConfigurationBundleVersion.mockRejectedValueOnce(new Error('404 not found')); + mockListConfigurationBundles.mockResolvedValue({ bundles: [] }); + mockCreateConfigurationBundle.mockResolvedValue({ + bundleId: 'b-new', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-new', + versionId: 'v-1', + }); + + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([ + { name: 'MyBundle', type: 'ConfigurationBundle', components: { x: { type: 'inline', value: '1' } } }, + ]), + existingBundles, + }); + + expect(mockCreateConfigurationBundle).toHaveBeenCalled(); + expect(result.results[0]).toMatchObject({ status: 'created', bundleId: 'b-new' }); + }); + }); + + describe('error handling', () => { + it('should report error status when create fails', async () => { + mockListConfigurationBundles.mockResolvedValue({ bundles: [] }); + mockCreateConfigurationBundle.mockRejectedValue(new Error('Service unavailable')); + + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([ + { name: 'MyBundle', type: 'ConfigurationBundle', components: { x: { type: 'inline', value: '1' } } }, + ]), + }); + + expect(result.results[0]).toMatchObject({ + bundleName: 'MyBundle', + status: 'error', + error: 'Service unavailable', + }); + expect(result.hasErrors).toBe(true); + }); + + it('should report error status when update fails with non-404 error', async () => { + const existingBundles = { + MyBundle: { + bundleId: 'b-123', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', + versionId: 'v-1', + }, + }; + + mockGetConfigurationBundleVersion.mockRejectedValue(new Error('Throttling exception')); + + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([ + { name: 'MyBundle', type: 'ConfigurationBundle', components: { x: { type: 'inline', value: '1' } } }, + ]), + existingBundles, + }); + + expect(result.results[0]).toMatchObject({ + bundleName: 'MyBundle', + status: 'error', + error: 'Throttling exception', + }); + expect(result.hasErrors).toBe(true); + // Should NOT fall through to findBundleByName + expect(mockListConfigurationBundles).not.toHaveBeenCalled(); + }); + + it('should report error when delete throws an exception', async () => { + const existingBundles = { + OrphanBundle: { + bundleId: 'b-orphan', + bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-orphan', + versionId: 'v-1', + }, + }; + + mockDeleteConfigurationBundle.mockRejectedValue(new Error('Network error')); + + const result = await setupConfigBundles({ + region: REGION, + projectSpec: makeProjectSpec([]), + existingBundles, + }); + + expect(result.results[0]).toMatchObject({ + bundleName: 'OrphanBundle', + status: 'error', + error: 'Network error', + }); + expect(result.hasErrors).toBe(true); + }); + }); +}); + +// ── resolveConfigBundleComponentKeys ─────────────────────────────────────── + +describe('resolveConfigBundleComponentKeys', () => { + function makeFullProjectSpec(configBundles: AgentCoreProjectSpec['configBundles'] = []): AgentCoreProjectSpec { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles, + httpGateways: [], + abTests: [], + }; + } + + function makeDeployedState(targetName: string, resources: Record): DeployedState { + return { + targets: { + [targetName]: { resources }, + }, + } as unknown as DeployedState; + } + + it('returns projectSpec unchanged when target has no resources', () => { + const spec = makeFullProjectSpec([ + { name: 'b1', components: { '{{runtime:my-rt}}': { configuration: { k: 'v' } } } } as any, + ]); + const deployedState = { targets: {} } as unknown as DeployedState; + + const result = resolveConfigBundleComponentKeys(spec, deployedState, 'missing-target'); + expect(result).toBe(spec); // same reference — no transformation + }); + + it('resolves {{runtime:name}} placeholder to runtime ARN', () => { + const spec = makeFullProjectSpec([ + { name: 'b1', components: { '{{runtime:my-agent}}': { configuration: { k: 'v' } } } } as any, + ]); + const deployedState = makeDeployedState('target1', { + runtimes: { 'my-agent': { runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1' } }, + }); + + const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); + const keys = Object.keys(result.configBundles[0]!.components); + expect(keys).toEqual(['arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1']); + }); + + it('resolves {{gateway:name}} placeholder to HTTP gateway ARN', () => { + const spec = makeFullProjectSpec([ + { name: 'b1', components: { '{{gateway:my-gw}}': { configuration: { k: 'v' } } } } as any, + ]); + const deployedState = makeDeployedState('target1', { + httpGateways: { 'my-gw': { gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-1' } }, + }); + + const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); + const keys = Object.keys(result.configBundles[0]!.components); + expect(keys).toEqual(['arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-1']); + }); + + it('resolves {{gateway:name}} placeholder to MCP gateway ARN', () => { + const spec = makeFullProjectSpec([ + { name: 'b1', components: { '{{gateway:my-mcp-gw}}': { configuration: { k: 'v' } } } } as any, + ]); + const deployedState = makeDeployedState('target1', { + mcp: { gateways: { 'my-mcp-gw': { gatewayArn: 'arn:mcp:gw:resolved' } } }, + }); + + const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); + const keys = Object.keys(result.configBundles[0]!.components); + expect(keys).toEqual(['arn:mcp:gw:resolved']); + }); + + it('passes through keys that are already ARNs', () => { + const spec = makeFullProjectSpec([ + { name: 'b1', components: { 'arn:existing:key': { configuration: { k: 'v' } } } } as any, + ]); + const deployedState = makeDeployedState('target1', { runtimes: {} }); + + const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); + const keys = Object.keys(result.configBundles[0]!.components); + expect(keys).toEqual(['arn:existing:key']); + }); + + it('passes through plain string keys that are not placeholders or ARNs', () => { + const spec = makeFullProjectSpec([ + { name: 'b1', components: { 'some-plain-key': { configuration: { k: 'v' } } } } as any, + ]); + const deployedState = makeDeployedState('target1', { runtimes: {} }); + + const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); + const keys = Object.keys(result.configBundles[0]!.components); + expect(keys).toEqual(['some-plain-key']); + }); + + it('throws when gateway placeholder references non-existent gateway', () => { + const spec = makeFullProjectSpec([ + { name: 'b1', components: { '{{gateway:missing}}': { configuration: {} } } } as any, + ]); + const deployedState = makeDeployedState('target1', { httpGateways: {}, mcp: { gateways: {} } }); + + expect(() => resolveConfigBundleComponentKeys(spec, deployedState, 'target1')).toThrow( + 'Config bundle references gateway "missing" but it was not found in deployed resources' + ); + }); + + it('throws when runtime placeholder references non-existent runtime', () => { + const spec = makeFullProjectSpec([ + { name: 'b1', components: { '{{runtime:missing}}': { configuration: {} } } } as any, + ]); + const deployedState = makeDeployedState('target1', { runtimes: {} }); + + expect(() => resolveConfigBundleComponentKeys(spec, deployedState, 'target1')).toThrow( + 'Config bundle references runtime "missing" but it was not found in deployed resources' + ); + }); + + it('handles projectSpec with no configBundles', () => { + const spec = makeFullProjectSpec([]); + const deployedState = makeDeployedState('target1', { runtimes: {} }); + + const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); + expect(result.configBundles).toEqual([]); + }); + + it('does not mutate the original projectSpec', () => { + const spec = makeFullProjectSpec([ + { name: 'b1', components: { '{{runtime:my-rt}}': { configuration: { k: 'v' } } } } as any, + ]); + const deployedState = makeDeployedState('target1', { + runtimes: { 'my-rt': { runtimeArn: 'arn:resolved' } }, + }); + + const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); + // Original should still have the placeholder + expect(Object.keys(spec.configBundles[0]!.components)).toEqual(['{{runtime:my-rt}}']); + // Result should have the resolved key + expect(Object.keys(result.configBundles[0]!.components)).toEqual(['arn:resolved']); + }); + + it('prefers HTTP gateway over MCP gateway when both exist with same name', () => { + const spec = makeFullProjectSpec([ + { name: 'b1', components: { '{{gateway:dupe-gw}}': { configuration: {} } } } as any, + ]); + const deployedState = makeDeployedState('target1', { + httpGateways: { 'dupe-gw': { gatewayArn: 'arn:http:gw' } }, + mcp: { gateways: { 'dupe-gw': { gatewayArn: 'arn:mcp:gw' } } }, + }); + + const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); + const keys = Object.keys(result.configBundles[0]!.components); + // HTTP gateway should take precedence (checked first in code) + expect(keys).toEqual(['arn:http:gw']); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts new file mode 100644 index 000000000..32c7e6252 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts @@ -0,0 +1,468 @@ +import type { AgentCoreProjectSpec, DeployedResourceState, HttpGatewayDeployedState } from '../../../../schema'; +import { deleteOrphanedHttpGateways, setupHttpGateways } from '../post-deploy-http-gateways.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Hoisted mocks ────────────────────────────────────────────────────────── + +const { + mockCreateHttpGateway, + mockCreateHttpGatewayTarget, + mockDeleteHttpGateway, + mockDeleteHttpGatewayTarget, + mockListAllHttpGateways, + mockListHttpGatewayTargets, + mockWaitForGatewayReady, + mockWaitForTargetReady, + mockGetCredentialProvider, + mockIAMSend, +} = vi.hoisted(() => ({ + mockCreateHttpGateway: vi.fn(), + mockCreateHttpGatewayTarget: vi.fn(), + mockDeleteHttpGateway: vi.fn(), + mockDeleteHttpGatewayTarget: vi.fn(), + mockListAllHttpGateways: vi.fn(), + mockListHttpGatewayTargets: vi.fn(), + mockWaitForGatewayReady: vi.fn(), + mockWaitForTargetReady: vi.fn(), + mockGetCredentialProvider: vi.fn().mockReturnValue(undefined), + mockIAMSend: vi.fn(), +})); + +vi.mock('../../../aws/agentcore-http-gateways', () => ({ + createHttpGateway: mockCreateHttpGateway, + createHttpGatewayTarget: mockCreateHttpGatewayTarget, + deleteHttpGateway: mockDeleteHttpGateway, + deleteHttpGatewayTarget: mockDeleteHttpGatewayTarget, + listAllHttpGateways: mockListAllHttpGateways, + listHttpGatewayTargets: mockListHttpGatewayTargets, + waitForGatewayReady: mockWaitForGatewayReady, + waitForTargetReady: mockWaitForTargetReady, +})); + +vi.mock('../../../aws/account', () => ({ + getCredentialProvider: mockGetCredentialProvider, +})); + +vi.mock('@aws-sdk/client-iam', () => ({ + IAMClient: class { + send = mockIAMSend; + }, + CreateRoleCommand: class { + constructor(public input: unknown) {} + }, + GetRoleCommand: class { + constructor(public input: unknown) {} + }, + PutRolePolicyCommand: class { + constructor(public input: unknown) {} + }, + DeleteRolePolicyCommand: class { + constructor(public input: unknown) {} + }, + DeleteRoleCommand: class { + constructor(public input: unknown) {} + }, +})); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function makeProjectSpec(httpGateways: AgentCoreProjectSpec['httpGateways'] = []): AgentCoreProjectSpec { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests: [], + httpGateways, + }; +} + +const sampleHttpGateway = { + name: 'MyHttpGw', + runtimeRef: 'my-agent', + roleArn: 'arn:aws:iam::123456789012:role/ExistingRole', +}; + +const sampleDeployedResources = { + runtimes: { + 'my-agent': { + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/rt-123', + runtimeId: 'rt-123', + }, + }, +} as unknown as DeployedResourceState; + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('setupHttpGateways', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListAllHttpGateways.mockResolvedValue([]); + mockListHttpGatewayTargets.mockResolvedValue({ targets: [] }); + mockWaitForGatewayReady.mockResolvedValue({ gatewayId: 'gw-001', status: 'READY' }); + mockWaitForTargetReady.mockResolvedValue({}); + }); + + describe('creation', () => { + it('creates gateway + target for new spec entry', async () => { + mockCreateHttpGateway.mockResolvedValue({ + gatewayId: 'gw-001', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:httpgateway/gw-001', + }); + mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-001' }); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + deployedResources: sampleDeployedResources, + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('created'); + expect(result.results[0]!.gatewayId).toBe('gw-001'); + expect(result.httpGateways.MyHttpGw).toEqual( + expect.objectContaining({ + gatewayId: 'gw-001', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:httpgateway/gw-001', + targetId: 'tgt-001', + }) + ); + + expect(mockCreateHttpGateway).toHaveBeenCalledWith({ + region: 'us-east-1', + name: 'TestProject-MyHttpGw', + roleArn: 'arn:aws:iam::123456789012:role/ExistingRole', + }); + expect(mockCreateHttpGatewayTarget).toHaveBeenCalledWith({ + region: 'us-east-1', + gatewayId: 'gw-001', + targetName: 'TestProject-my-agent', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/rt-123', + }); + }); + + it('skips existing gateway', async () => { + const existingHttpGateways: Record = { + MyHttpGw: { + gatewayId: 'gw-existing', + gatewayArn: 'arn:httpgw:existing', + targetId: 'tgt-existing', + }, + }; + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + existingHttpGateways, + deployedResources: sampleDeployedResources, + }); + + expect(result.results[0]!.status).toBe('skipped'); + expect(result.results[0]!.gatewayId).toBe('gw-existing'); + expect(mockCreateHttpGateway).not.toHaveBeenCalled(); + expect(mockCreateHttpGatewayTarget).not.toHaveBeenCalled(); + }); + + it('finds gateway by name via list (state loss recovery)', async () => { + mockListAllHttpGateways.mockResolvedValue([ + { name: 'TestProject-MyHttpGw', gatewayId: 'gw-api', gatewayArn: 'arn:httpgw:api' }, + ]); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + deployedResources: sampleDeployedResources, + }); + + expect(result.results[0]!.status).toBe('skipped'); + expect(result.httpGateways.MyHttpGw!.gatewayId).toBe('gw-api'); + expect(mockCreateHttpGateway).not.toHaveBeenCalled(); + }); + + it('recovers state using legacy (pre-migration) gateway name when prefixed name not found', async () => { + // First call: prefixed name "TestProject-MyHttpGw" → not found + // Second call: unprefixed legacy name "MyHttpGw" → found + mockListAllHttpGateways + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ name: 'MyHttpGw', gatewayId: 'gw-legacy', gatewayArn: 'arn:httpgw:legacy' }]); + + const warnSpy = vi.spyOn(console, 'warn').mockReturnValue(undefined); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + deployedResources: sampleDeployedResources, + }); + + // findHttpGatewayByName was called twice: once for prefixed, once for unprefixed name + expect(mockListAllHttpGateways).toHaveBeenCalledTimes(2); + + // Gateway result is skipped (not created) + expect(result.results[0]!.status).toBe('skipped'); + expect(result.results[0]!.gatewayId).toBe('gw-legacy'); + expect(result.httpGateways.MyHttpGw!.gatewayId).toBe('gw-legacy'); + + // createHttpGateway was NOT called + expect(mockCreateHttpGateway).not.toHaveBeenCalled(); + + // console.warn was called with the pre-migration warning text + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('pre-migration name')); + + warnSpy.mockRestore(); + }); + + it('reports error on missing runtime ref', async () => { + const emptyDeployedResources = {} as unknown as DeployedResourceState; + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + deployedResources: emptyDeployedResources, + }); + + expect(result.hasErrors).toBe(true); + expect(result.results[0]!.status).toBe('error'); + expect(result.results[0]!.error).toContain('Runtime "my-agent" not found'); + expect(mockCreateHttpGateway).not.toHaveBeenCalled(); + }); + + it('auto-creates IAM role when roleArn not provided', async () => { + const gwWithoutRole = { ...sampleHttpGateway, roleArn: undefined }; + mockCreateHttpGateway.mockResolvedValue({ + gatewayId: 'gw-002', + gatewayArn: 'arn:httpgw:002', + }); + mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-002' }); + mockIAMSend.mockResolvedValue({ Role: { Arn: 'arn:aws:iam::123:role/AutoRole' } }); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([gwWithoutRole]), + deployedResources: sampleDeployedResources, + }); + + expect(result.results[0]!.status).toBe('created'); + expect(result.httpGateways.MyHttpGw!.roleCreatedByCli).toBe(true); + expect(mockIAMSend).toHaveBeenCalled(); + + // Verify CreateRoleCommand was sent with correct trust policy + const createRoleCall = mockIAMSend.mock.calls[0]![0]; + const trustPolicy = JSON.parse(createRoleCall.input.AssumeRolePolicyDocument); + expect(trustPolicy.Statement[0].Principal.Service).toBe('bedrock-agentcore.amazonaws.com'); + + // Verify PutRolePolicyCommand was sent with correct inline policy actions + const putPolicyCall = mockIAMSend.mock.calls[1]![0]; + const inlinePolicy = JSON.parse(putPolicyCall.input.PolicyDocument); + const actions = inlinePolicy.Statement[0].Action; + expect(actions).toContain('bedrock-agentcore:InvokeRuntime'); + expect(actions).toContain('bedrock-agentcore:InvokeAgent'); + expect(actions).toContain('bedrock-agentcore:InvokeAgentRuntime'); + expect(inlinePolicy.Statement[0].Resource).toBe('*'); + }); + + it('rollback on target creation failure', async () => { + mockCreateHttpGateway.mockResolvedValue({ + gatewayId: 'gw-rollback', + gatewayArn: 'arn:httpgw:rollback', + }); + mockCreateHttpGatewayTarget.mockRejectedValue(new Error('Target creation failed')); + mockDeleteHttpGateway.mockResolvedValue({ success: true }); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + deployedResources: sampleDeployedResources, + }); + + expect(result.hasErrors).toBe(true); + expect(result.results[0]!.status).toBe('error'); + expect(result.results[0]!.error).toContain('Target creation failed'); + expect(result.results[0]!.error).toContain('gateway rolled back'); + + // Verify rollback: deleteHttpGateway was called + expect(mockDeleteHttpGateway).toHaveBeenCalledWith({ + region: 'us-east-1', + gatewayId: 'gw-rollback', + }); + }); + }); + + describe('deletion (reconciliation)', () => { + it('deletes orphaned gateway not in project spec', async () => { + mockDeleteHttpGateway.mockResolvedValue({ success: true }); + mockDeleteHttpGatewayTarget.mockResolvedValue({ success: true }); + + const result = await deleteOrphanedHttpGateways({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingHttpGateways: { + RemovedGw: { + gatewayId: 'gw-old', + gatewayArn: 'arn:httpgw:old', + targetId: 'tgt-old', + }, + }, + }); + + expect(mockDeleteHttpGatewayTarget).toHaveBeenCalledWith({ + region: 'us-east-1', + gatewayId: 'gw-old', + targetId: 'tgt-old', + }); + expect(mockDeleteHttpGateway).toHaveBeenCalledWith({ + region: 'us-east-1', + gatewayId: 'gw-old', + }); + expect(result.results[0]!.status).toBe('deleted'); + }); + + it('cleans up auto-created IAM role on deletion', async () => { + mockDeleteHttpGateway.mockResolvedValue({ success: true }); + mockIAMSend.mockResolvedValue({}); + + await deleteOrphanedHttpGateways({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingHttpGateways: { + RemovedGw: { + gatewayId: 'gw-old', + gatewayArn: 'arn:httpgw:old', + roleArn: 'arn:aws:iam::123:role/AutoCreatedRole', + roleCreatedByCli: true, + }, + }, + }); + + // Should have called delete policy + delete role + expect(mockIAMSend).toHaveBeenCalledTimes(2); + + // Verify first call is DeleteRolePolicyCommand + const firstCall = mockIAMSend.mock.calls[0]![0]; + expect(firstCall.input).toEqual( + expect.objectContaining({ RoleName: 'AutoCreatedRole', PolicyName: expect.any(String) }) + ); + + // Verify second call is DeleteRoleCommand + const secondCall = mockIAMSend.mock.calls[1]![0]; + expect(secondCall.input).toEqual(expect.objectContaining({ RoleName: 'AutoCreatedRole' })); + }); + + it('reports error when deletion fails', async () => { + mockDeleteHttpGateway.mockRejectedValue(new Error('delete failed')); + + const result = await deleteOrphanedHttpGateways({ + region: 'us-east-1', + projectSpec: makeProjectSpec([]), + existingHttpGateways: { + FailGw: { gatewayId: 'gw-fail', gatewayArn: 'arn:httpgw:fail' }, + }, + }); + + expect(result.hasErrors).toBe(true); + expect(result.results[0]!.status).toBe('error'); + expect(result.results[0]!.error).toBe('delete failed'); + }); + }); + + describe('edge cases', () => { + it('proceeds with creation when listHttpGateways fails', async () => { + mockListAllHttpGateways.mockRejectedValue(new Error('API unavailable')); + mockCreateHttpGateway.mockResolvedValue({ + gatewayId: 'gw-new', + gatewayArn: 'arn:httpgw:new', + }); + mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-new' }); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + deployedResources: sampleDeployedResources, + }); + + expect(result.results[0]!.status).toBe('created'); + expect(mockCreateHttpGateway).toHaveBeenCalled(); + }); + + it('uses provided roleArn without creating IAM role', async () => { + mockCreateHttpGateway.mockResolvedValue({ + gatewayId: 'gw-003', + gatewayArn: 'arn:httpgw:003', + }); + mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-003' }); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([sampleHttpGateway]), + deployedResources: sampleDeployedResources, + }); + + expect(result.results[0]!.status).toBe('created'); + expect(result.httpGateways.MyHttpGw!.roleCreatedByCli).toBe(false); + expect(mockIAMSend).not.toHaveBeenCalled(); + }); + }); + + describe('mixed operations', () => { + it('creates new and skips existing (orphan deletion is a separate pass)', async () => { + const newGw = { ...sampleHttpGateway, name: 'NewGw' }; + const keptGw = { ...sampleHttpGateway, name: 'KeptGw' }; + + mockCreateHttpGateway.mockResolvedValue({ + gatewayId: 'gw-new', + gatewayArn: 'arn:httpgw:new', + }); + mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-new' }); + mockDeleteHttpGateway.mockResolvedValue({ success: true }); + + const result = await setupHttpGateways({ + region: 'us-east-1', + projectName: 'TestProject', + projectSpec: makeProjectSpec([newGw, keptGw]), + existingHttpGateways: { + KeptGw: { gatewayId: 'gw-kept', gatewayArn: 'arn:httpgw:kept' }, + OrphanGw: { gatewayId: 'gw-orphan', gatewayArn: 'arn:httpgw:orphan' }, + }, + deployedResources: sampleDeployedResources, + }); + + expect(result.results).toHaveLength(2); + const statuses = result.results.map(r => `${r.gatewayName}:${r.status}`); + expect(statuses).toContain('NewGw:created'); + expect(statuses).toContain('KeptGw:skipped'); + }); + + it('deleteOrphanedHttpGateways removes orphans separately', async () => { + mockDeleteHttpGateway.mockResolvedValue({ success: true }); + + const result = await deleteOrphanedHttpGateways({ + region: 'us-east-1', + projectSpec: makeProjectSpec([{ ...sampleHttpGateway, name: 'KeptGw' }]), + existingHttpGateways: { + KeptGw: { gatewayId: 'gw-kept', gatewayArn: 'arn:httpgw:kept' }, + OrphanGw: { gatewayId: 'gw-orphan', gatewayArn: 'arn:httpgw:orphan' }, + }, + }); + + expect(result.results).toHaveLength(1); + expect(result.results[0]!.gatewayName).toBe('OrphanGw'); + expect(result.results[0]!.status).toBe('deleted'); + }); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/post-deploy-observability.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-observability.test.ts index 9155a699d..ba069f29e 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-observability.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-observability.test.ts @@ -1,23 +1,23 @@ import { setupTransactionSearch } from '../post-deploy-observability.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockEnableTransactionSearch, mockReadCliConfig } = vi.hoisted(() => ({ +const { mockEnableTransactionSearch, mockReadGlobalConfigSync } = vi.hoisted(() => ({ mockEnableTransactionSearch: vi.fn(), - mockReadCliConfig: vi.fn(), + mockReadGlobalConfigSync: vi.fn(), })); vi.mock('../../../aws/transaction-search', () => ({ enableTransactionSearch: mockEnableTransactionSearch, })); -vi.mock('../../../../lib/schemas/io/cli-config', () => ({ - readCliConfig: mockReadCliConfig, +vi.mock('../../../../lib/schemas/io/global-config', () => ({ + readGlobalConfigSync: mockReadGlobalConfigSync, })); describe('setupTransactionSearch', () => { beforeEach(() => { vi.clearAllMocks(); - mockReadCliConfig.mockReturnValue({}); + mockReadGlobalConfigSync.mockReturnValue({}); mockEnableTransactionSearch.mockResolvedValue({ success: true }); }); @@ -33,7 +33,7 @@ describe('setupTransactionSearch', () => { }); it('passes custom transactionSearchIndexPercentage from config', async () => { - mockReadCliConfig.mockReturnValue({ transactionSearchIndexPercentage: 25 }); + mockReadGlobalConfigSync.mockReturnValue({ transactionSearchIndexPercentage: 25 }); const result = await setupTransactionSearch({ region: 'us-east-1', @@ -57,7 +57,7 @@ describe('setupTransactionSearch', () => { }); it('skips when disableTransactionSearch is true in config', async () => { - mockReadCliConfig.mockReturnValue({ disableTransactionSearch: true }); + mockReadGlobalConfigSync.mockReturnValue({ disableTransactionSearch: true }); const result = await setupTransactionSearch({ region: 'us-east-1', diff --git a/src/cli/operations/deploy/__tests__/post-deploy-online-evals.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-online-evals.test.ts new file mode 100644 index 000000000..8120167ae --- /dev/null +++ b/src/cli/operations/deploy/__tests__/post-deploy-online-evals.test.ts @@ -0,0 +1,179 @@ +import { enableOnlineEvalConfigs } from '../post-deploy-online-evals'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockUpdateOnlineEvalExecutionStatus } = vi.hoisted(() => ({ + mockUpdateOnlineEvalExecutionStatus: vi.fn(), +})); + +vi.mock('../../../aws/agentcore-control', () => ({ + updateOnlineEvalExecutionStatus: mockUpdateOnlineEvalExecutionStatus, +})); + +function makeOnlineEvalConfig(overrides: Record = {}) { + return { + name: 'MyEval', + agent: 'my-agent', + evaluators: ['Builtin.Faithfulness'], + samplingRate: 10, + enableOnCreate: true, + ...overrides, + }; +} + +const deployedConfigs = { + MyEval: { + onlineEvaluationConfigId: 'oec-123', + onlineEvaluationConfigArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/oec-123', + }, +}; + +describe('enableOnlineEvalConfigs', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUpdateOnlineEvalExecutionStatus.mockResolvedValue({ + configId: 'oec-123', + executionStatus: 'ENABLED', + status: 'ACTIVE', + }); + }); + + describe('enablement', () => { + it('enables config with enableOnCreate true', async () => { + const result = await enableOnlineEvalConfigs({ + region: 'us-east-1', + onlineEvalConfigs: [makeOnlineEvalConfig()], + deployedOnlineEvalConfigs: deployedConfigs, + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('enabled'); + expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalledWith({ + region: 'us-east-1', + onlineEvaluationConfigId: 'oec-123', + executionStatus: 'ENABLED', + }); + }); + + it('enables config when enableOnCreate is undefined (defaults to enable)', async () => { + const result = await enableOnlineEvalConfigs({ + region: 'us-east-1', + onlineEvalConfigs: [makeOnlineEvalConfig({ enableOnCreate: undefined })], + deployedOnlineEvalConfigs: deployedConfigs, + }); + + expect(result.hasErrors).toBe(false); + expect(result.results[0]!.status).toBe('enabled'); + expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalled(); + }); + + it('skips config with enableOnCreate false', async () => { + const result = await enableOnlineEvalConfigs({ + region: 'us-east-1', + onlineEvalConfigs: [makeOnlineEvalConfig({ enableOnCreate: false })], + deployedOnlineEvalConfigs: deployedConfigs, + }); + + expect(result.hasErrors).toBe(false); + expect(result.results[0]!.status).toBe('skipped'); + expect(mockUpdateOnlineEvalExecutionStatus).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('reports error when config not in deployed state', async () => { + const result = await enableOnlineEvalConfigs({ + region: 'us-east-1', + onlineEvalConfigs: [makeOnlineEvalConfig({ name: 'Missing' })], + deployedOnlineEvalConfigs: deployedConfigs, + }); + + expect(result.hasErrors).toBe(true); + expect(result.results[0]!.status).toBe('error'); + expect(result.results[0]!.error).toContain('not found in deployed state'); + }); + + it('reports error when API call fails', async () => { + mockUpdateOnlineEvalExecutionStatus.mockRejectedValue(new Error('AccessDenied')); + + const result = await enableOnlineEvalConfigs({ + region: 'us-east-1', + onlineEvalConfigs: [makeOnlineEvalConfig()], + deployedOnlineEvalConfigs: deployedConfigs, + }); + + expect(result.hasErrors).toBe(true); + expect(result.results[0]!.status).toBe('error'); + expect(result.results[0]!.error).toBe('AccessDenied'); + }); + + it('hasErrors is true when any config fails', async () => { + mockUpdateOnlineEvalExecutionStatus + .mockResolvedValueOnce({ configId: 'oec-123', executionStatus: 'ENABLED', status: 'ACTIVE' }) + .mockRejectedValueOnce(new Error('Throttled')); + + const result = await enableOnlineEvalConfigs({ + region: 'us-east-1', + onlineEvalConfigs: [makeOnlineEvalConfig({ name: 'MyEval' }), makeOnlineEvalConfig({ name: 'OtherEval' })], + deployedOnlineEvalConfigs: { + ...deployedConfigs, + OtherEval: { + onlineEvaluationConfigId: 'oec-456', + onlineEvaluationConfigArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/oec-456', + }, + }, + }); + + expect(result.hasErrors).toBe(true); + expect(result.results[0]!.status).toBe('enabled'); + expect(result.results[1]!.status).toBe('error'); + }); + }); + + describe('multiple configs', () => { + it('processes multiple configs independently', async () => { + const result = await enableOnlineEvalConfigs({ + region: 'us-east-1', + onlineEvalConfigs: [makeOnlineEvalConfig({ name: 'MyEval' }), makeOnlineEvalConfig({ name: 'OtherEval' })], + deployedOnlineEvalConfigs: { + ...deployedConfigs, + OtherEval: { + onlineEvaluationConfigId: 'oec-456', + onlineEvaluationConfigArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/oec-456', + }, + }, + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(2); + expect(result.results[0]!.status).toBe('enabled'); + expect(result.results[1]!.status).toBe('enabled'); + expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalledTimes(2); + }); + + it('mixed enableOnCreate values', async () => { + const result = await enableOnlineEvalConfigs({ + region: 'us-east-1', + onlineEvalConfigs: [ + makeOnlineEvalConfig({ name: 'MyEval', enableOnCreate: true }), + makeOnlineEvalConfig({ name: 'OtherEval', enableOnCreate: false }), + ], + deployedOnlineEvalConfigs: { + ...deployedConfigs, + OtherEval: { + onlineEvaluationConfigId: 'oec-456', + onlineEvaluationConfigArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/oec-456', + }, + }, + }); + + expect(result.hasErrors).toBe(false); + expect(result.results[0]!.status).toBe('enabled'); + expect(result.results[1]!.status).toBe('skipped'); + expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts index 12e172d17..04b75bb52 100644 --- a/src/cli/operations/deploy/__tests__/preflight.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight.test.ts @@ -32,10 +32,20 @@ vi.mock('../../../../lib/index.js', () => ({ resolveAWSDeploymentTargets = mockReadAWSDeploymentTargets; readDeployedState = mockReadDeployedState; configExists = mockConfigExists; + getPathResolver = () => ({ getAgentConfigPath: () => '/tmp/mock-agentcore.json' }); }, requireConfigRoot: mockRequireConfigRoot, })); +vi.mock('node:fs', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync: () => JSON.stringify({}), + writeFileSync: vi.fn(), + }; +}); + vi.mock('../../../cdk/local-cdk-project.js', () => ({ LocalCdkProject: class { validate = mockValidate; @@ -116,6 +126,51 @@ describe('validateProject', () => { expect(result.projectSpec.name).toBe('test-project'); expect(result.isTeardownDeploy).toBe(false); }); + + it('rejects gateway target name that exceeds 48 chars when prefixed with project name', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + // projectName "myproject" (9) + "-" (1) + targetName (39) = 49 > 48 + mockReadProjectSpec.mockResolvedValue({ + name: 'myproject', + runtimes: [], + httpGateways: [ + { + name: 'gw', + targets: [{ name: 'a'.repeat(39), runtimeRef: 'rt', qualifier: 'DEFAULT' }], + }, + ], + agentCoreGateways: [{ name: 'gw' }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + await expect(validateProject()).rejects.toThrow( + 'HTTP gateway target "' + 'a'.repeat(39) + '" in gateway "gw" would exceed the 48-character AWS limit' + ); + }); + + it('accepts gateway target name within 48 chars when prefixed with project name', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + // projectName "myproject" (9) + "-" (1) + targetName (38) = 48 == limit + mockReadProjectSpec.mockResolvedValue({ + name: 'myproject', + runtimes: [], + httpGateways: [ + { + name: 'gw', + targets: [{ name: 'a'.repeat(38), runtimeRef: 'rt', qualifier: 'DEFAULT' }], + }, + ], + agentCoreGateways: [{ name: 'gw' }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + expect(result.projectSpec.name).toBe('myproject'); + }); }); describe('formatError', () => { diff --git a/src/cli/operations/deploy/index.ts b/src/cli/operations/deploy/index.ts index a5b9a2f9d..332f0ca2d 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -45,6 +45,30 @@ export { // Post-deploy observability setup export { setupTransactionSearch, type TransactionSearchSetupResult } from './post-deploy-observability'; +// Post-deploy HTTP gateways +export { + setupHttpGateways, + type SetupHttpGatewaysOptions, + type SetupHttpGatewaysResult, + type HttpGatewaySetupResult, +} from './post-deploy-http-gateways'; + +// Post-deploy online eval enablement +export { + enableOnlineEvalConfigs, + type EnableOnlineEvalsOptions, + type EnableOnlineEvalsResult, + type OnlineEvalEnableResult, +} from './post-deploy-online-evals'; + +// Post-deploy config bundles +export { + setupConfigBundles, + type SetupConfigBundlesOptions, + type SetupConfigBundlesResult, + type ConfigBundleSetupResult, +} from './post-deploy-config-bundles'; + // Re-export external requirements for convenience export { checkDependencyVersions, diff --git a/src/cli/operations/deploy/post-deploy-ab-tests.ts b/src/cli/operations/deploy/post-deploy-ab-tests.ts new file mode 100644 index 000000000..d4c6d4314 --- /dev/null +++ b/src/cli/operations/deploy/post-deploy-ab-tests.ts @@ -0,0 +1,723 @@ +import type { ABTestDeployedState, AgentCoreProjectSpec, DeployedResourceState } from '../../../schema'; +import { getCredentialProvider } from '../../aws/account'; +import { createABTest, deleteABTest, getABTest, listABTests, updateABTest } from '../../aws/agentcore-ab-tests'; +import type { ABTestEvaluationConfig, ABTestVariant, TrafficAllocationConfig } from '../../aws/agentcore-ab-tests'; +import { arnPrefix } from '../../aws/partition'; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + GetRoleCommand, + IAMClient, + PutRolePolicyCommand, +} from '@aws-sdk/client-iam'; +import { createHash } from 'node:crypto'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface SetupABTestsOptions { + region: string; + projectSpec: AgentCoreProjectSpec; + existingABTests?: Record; + /** Full deployed resource state for resolving ARN references. */ + deployedResources?: DeployedResourceState; +} + +export interface ABTestSetupResult { + testName: string; + status: 'created' | 'updated' | 'deleted' | 'skipped' | 'error'; + abTestId?: string; + abTestArn?: string; + error?: string; + warning?: string; +} + +export interface SetupABTestsResult { + results: ABTestSetupResult[]; + abTests: Record; + hasErrors: boolean; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const AB_TEST_ROLE_POLICY_NAME = 'ABTestExecutionPolicy'; + +// ============================================================================ +// Config Hash +// ============================================================================ + +/** + * Compute a deterministic SHA-256 hash of the key AB test configuration fields. + * Used to detect whether a redeployment actually changed the test config. + */ +function computeConfigHash(testSpec: { + variants: unknown; + evaluationConfig: unknown; + gatewayRef: string; + gatewayFilter?: unknown; + trafficAllocationConfig?: unknown; +}): string { + const payload = JSON.stringify({ + variants: testSpec.variants, + evaluationConfig: testSpec.evaluationConfig, + gatewayRef: testSpec.gatewayRef, + gatewayFilter: testSpec.gatewayFilter, + trafficAllocationConfig: testSpec.trafficAllocationConfig, + }); + return createHash('sha256').update(payload).digest('hex'); +} + +// ============================================================================ +// Shared Update Helper +// ============================================================================ + +interface ApplyABTestUpdateOptions { + region: string; + abTestId: string; + resolvedVariants: ABTestVariant[]; + resolvedEvalConfig: ABTestEvaluationConfig; + trafficAllocationConfig?: TrafficAllocationConfig; + resolvedRoleArn?: string; + testName: string; + roleCreatedByCli: boolean; + currentHash: string; +} + +async function applyABTestUpdate( + options: ApplyABTestUpdateOptions +): Promise<{ state: ABTestDeployedState; result: ABTestSetupResult }> { + const updateResult = await updateABTest({ + region: options.region, + abTestId: options.abTestId, + variants: options.resolvedVariants, + evaluationConfig: options.resolvedEvalConfig, + trafficAllocationConfig: options.trafficAllocationConfig, + roleArn: options.resolvedRoleArn, + }); + + return { + state: { + abTestId: updateResult.abTestId, + abTestArn: updateResult.abTestArn, + roleArn: options.resolvedRoleArn, + roleCreatedByCli: options.roleCreatedByCli, + configHash: options.currentHash, + }, + result: { + testName: options.testName, + status: 'updated', + abTestId: updateResult.abTestId, + abTestArn: updateResult.abTestArn, + }, + }; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Create, update, or delete AB tests post-deploy. + * + * Pattern: + * 1. For each AB test in project spec → resolve ARN references, create or skip + * 2. For each AB test in deployed-state but NOT in project spec → delete (reconciliation) + * 3. Return updated deployed state entries + */ +export async function setupABTests(options: SetupABTestsOptions): Promise { + const { region, projectSpec, existingABTests, deployedResources } = options; + const results: ABTestSetupResult[] = []; + const abTests: Record = {}; + + // Create or skip tests from the spec + for (const testSpec of projectSpec.abTests ?? []) { + let resolvedRoleArn: string | undefined; + let roleCreatedByCli = false; + try { + const currentHash = computeConfigHash(testSpec); + const existingTest = existingABTests?.[testSpec.name]; + + // Resolve ARN references from deployed state + const resolvedVariants = resolveVariants(testSpec.variants, projectSpec.name, deployedResources); + const resolvedGatewayArn = resolveGatewayArn(testSpec.gatewayRef, deployedResources); + if (!resolvedGatewayArn.startsWith('arn:') || resolvedGatewayArn.split(':').length < 6) { + results.push({ + testName: testSpec.name, + status: 'error', + error: `Gateway ARN could not be resolved for AB test "${testSpec.name}". Reference "${testSpec.gatewayRef}" did not match any deployed gateway. Ensure the HTTP gateway was deployed successfully.`, + }); + continue; + } + const resolvedEvalConfig = resolveEvalConfig(testSpec.evaluationConfig, deployedResources); + if (testSpec.roleArn) { + resolvedRoleArn = testSpec.roleArn; + } else { + resolvedRoleArn = await getOrCreateABTestRole({ + region, + projectName: projectSpec.name, + testName: testSpec.name, + gatewayArn: resolvedGatewayArn, + }); + roleCreatedByCli = true; + } + + if (existingTest) { + // Config unchanged — skip to preserve running state + if (existingTest.configHash === currentHash) { + abTests[testSpec.name] = existingTest; + results.push({ + testName: testSpec.name, + status: 'skipped', + abTestId: existingTest.abTestId, + abTestArn: existingTest.abTestArn, + }); + continue; + } + + // Config changed — update in-place instead of delete+recreate + const applied = await applyABTestUpdate({ + region, + abTestId: existingTest.abTestId, + resolvedVariants, + resolvedEvalConfig, + trafficAllocationConfig: testSpec.trafficAllocationConfig as TrafficAllocationConfig | undefined, + resolvedRoleArn, + testName: testSpec.name, + roleCreatedByCli: existingTest.roleCreatedByCli ?? roleCreatedByCli, + currentHash, + }); + abTests[testSpec.name] = applied.state; + results.push(applied.result); + continue; + } + + // Try to find by name via list (handles re-creation after state loss) + const existingByName = await findABTestByName(region, projectSpec.name, testSpec.name); + if (existingByName) { + // Found by name — update in-place with fresh config + const applied = await applyABTestUpdate({ + region, + abTestId: existingByName.abTestId, + resolvedVariants, + resolvedEvalConfig, + trafficAllocationConfig: testSpec.trafficAllocationConfig as TrafficAllocationConfig | undefined, + resolvedRoleArn, + testName: testSpec.name, + roleCreatedByCli, + currentHash, + }); + abTests[testSpec.name] = applied.state; + results.push(applied.result); + continue; + } + + const createOptions = { + region, + name: `${projectSpec.name}_${testSpec.name}`, + description: testSpec.description, + gatewayArn: resolvedGatewayArn, + roleArn: resolvedRoleArn, + variants: resolvedVariants, + evaluationConfig: resolvedEvalConfig, + gatewayFilter: testSpec.gatewayFilter, + trafficAllocationConfig: testSpec.trafficAllocationConfig as TrafficAllocationConfig | undefined, + maxDurationDays: testSpec.maxDurationDays, + enableOnCreate: testSpec.enableOnCreate, + }; + + // Retry on gateway/eval access denied — IAM policy propagation can take time + let result; + const MAX_RETRIES = 5; + const BASE_DELAY_MS = 5_000; + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + result = await createABTest(createOptions); + break; + } catch (err: unknown) { + const errCode = (err as { name?: string }).name; + const errStatus = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode; + const msg = err instanceof Error ? err.message : String(err); + + const isRetryable = + errCode === 'AccessDeniedException' || + errStatus === 403 || + msg.includes('Access denied') || + msg.includes('Gateway validation error'); + + if (isRetryable && attempt < MAX_RETRIES - 1) { + const delay = BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 1000; + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + throw err; + } + } + if (!result) throw new Error('AB test creation failed after retries'); + + abTests[testSpec.name] = { + abTestId: result.abTestId, + abTestArn: result.abTestArn, + roleArn: resolvedRoleArn, + roleCreatedByCli, + configHash: currentHash, + }; + + results.push({ + testName: testSpec.name, + status: 'created', + abTestId: result.abTestId, + abTestArn: result.abTestArn, + }); + } catch (err) { + // Clean up auto-created role on AB test creation failure to avoid orphaned roles + if (roleCreatedByCli && resolvedRoleArn) { + try { + await deleteABTestRole(region, resolvedRoleArn); + } catch { + // Best-effort role cleanup + } + } + results.push({ + testName: testSpec.name, + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Orphaned AB tests are deleted by deleteOrphanedABTests() which runs + // as a separate pre-pass before HTTP gateway setup. No deletion loop here. + + return { + results, + abTests, + hasErrors: results.some(r => r.status === 'error'), + }; +} + +/** + * Delete orphaned AB tests (in deployed-state but removed from spec). + * + * AB tests create rules on HTTP gateways, so they must be deleted before + * the gateway can be deleted. Call this before setupHttpGateways. + * + * The main setupABTests deletion loop becomes a no-op for any tests + * already cleaned up here. + */ +export async function deleteOrphanedABTests(options: { + region: string; + projectSpec: AgentCoreProjectSpec; + existingABTests?: Record; +}): Promise<{ results: ABTestSetupResult[]; hasErrors: boolean }> { + const { region, projectSpec, existingABTests } = options; + if (!existingABTests) return { results: [], hasErrors: false }; + + const specTestNames = new Set((projectSpec.abTests ?? []).map(t => t.name)); + const results: ABTestSetupResult[] = []; + + for (const [testName, testState] of Object.entries(existingABTests)) { + if (!specTestNames.has(testName)) { + try { + // Stop the AB test first — running tests cannot be deleted + let wasStopped = false; + let stopTimedOut = false; + try { + await updateABTest({ region, abTestId: testState.abTestId, executionStatus: 'STOPPED' }); + wasStopped = true; + + // Poll until executionStatus is STOPPED (stop is async) + let stopped = false; + for (let i = 0; i < 20; i++) { + const test = await getABTest({ region, abTestId: testState.abTestId }); + if (test.executionStatus === 'STOPPED') { + stopped = true; + break; + } + await new Promise(resolve => setTimeout(resolve, 3_000)); + } + if (!stopped) { + stopTimedOut = true; + } + } catch { + // May already be stopped or in a state that doesn't need stopping — proceed with delete + } + + const deleteResult = await deleteABTest({ + region, + abTestId: testState.abTestId, + }); + + if (deleteResult.success && testState.roleCreatedByCli && testState.roleArn) { + await deleteABTestRole(region, testState.roleArn); + } + + results.push({ + testName, + status: deleteResult.success ? 'deleted' : 'error', + error: deleteResult.error, + warning: stopTimedOut + ? `AB test "${testName}" did not reach STOPPED status within the polling window — proceeding with delete` + : wasStopped + ? `AB test "${testName}" was stopped before deletion` + : undefined, + }); + } catch (err) { + results.push({ + testName, + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + return { + results, + hasErrors: results.some(r => r.status === 'error'), + }; +} + +// ============================================================================ +// ARN Resolution Helpers +// ============================================================================ + +async function findABTestByName( + region: string, + projectName: string, + testName: string +): Promise<{ abTestId: string; abTestArn: string } | undefined> { + try { + const prefixedName = `${projectName}_${testName}`; + const result = await listABTests({ region, maxResults: 100 }); + return result.abTests.find( + t => t.name.toLowerCase() === prefixedName.toLowerCase() || t.name.toLowerCase() === testName.toLowerCase() + ); + } catch { + return undefined; + } +} + +/** + * Resolve variant config bundle references. + * If bundleArn is a name (not an ARN), look it up in deployed config bundles. + * Target-based variants have their target name prefixed with projectName to match + * what post-deploy-http-gateways.ts creates on AWS (e.g. `${projectName}-${tgt.name}`). + */ +function resolveVariants( + variants: { + name: 'C' | 'T1'; + weight: number; + variantConfiguration: { + configurationBundle?: { bundleArn: string; bundleVersion: string }; + target?: { targetName: string }; + }; + }[], + projectName: string, + deployedResources?: DeployedResourceState +): ABTestVariant[] { + return variants.map(v => { + const bundle = v.variantConfiguration.configurationBundle; + if (bundle) { + return { + name: v.name, + weight: v.weight, + variantConfiguration: { + configurationBundle: { + bundleArn: resolveConfigBundleArn(bundle.bundleArn, deployedResources), + bundleVersion: resolveConfigBundleVersion(bundle.bundleArn, bundle.bundleVersion, deployedResources), + }, + }, + }; + } + // Target-based variant — prepend projectName to match the AWS-side name created by + // post-deploy-http-gateways.ts: `${projectName}-${tgt.name}` + return { + name: v.name, + weight: v.weight, + variantConfiguration: { + ...(v.variantConfiguration.target && { + target: { name: resolveTargetName(v.variantConfiguration.target.targetName, projectName) }, + }), + }, + }; + }); +} + +function resolveConfigBundleArn(ref: string, deployedResources?: DeployedResourceState): string { + if (ref.startsWith('arn:')) return ref; + + const bundles = deployedResources?.configBundles; + if (bundles?.[ref]) { + return bundles[ref].bundleArn; + } + + return ref; +} + +function resolveConfigBundleVersion( + bundleRef: string, + versionRef: string, + deployedResources?: DeployedResourceState +): string { + if (versionRef !== 'LATEST') return versionRef; + + // Resolve LATEST to the deployed versionId + const bundles = deployedResources?.configBundles; + const name = bundleRef.startsWith('arn:') ? undefined : bundleRef; + if (name && bundles?.[name]) { + return bundles[name].versionId; + } + + return versionRef; +} + +/** + * Resolve a variant target name, applying the project prefix if not already present. + * This handles legacy configs that were created before the prefix requirement. + */ +function resolveTargetName(targetName: string, projectName: string): string { + // If the target name already starts with the project prefix, use as-is to avoid double-prefixing + if (targetName.startsWith(`${projectName}-`)) { + return targetName; + } + return `${projectName}-${targetName}`; +} + +function resolveGatewayArn(ref: string, deployedResources?: DeployedResourceState): string { + if (ref.startsWith('arn:')) return ref; + + // Check for placeholder pattern {{gateway:}} + const placeholderMatch = /^\{\{gateway:(.+)\}\}$/.exec(ref); + const gwName = placeholderMatch ? placeholderMatch[1] : ref; + + const gateways = deployedResources?.mcp?.gateways; + if (gateways && gwName && gateways[gwName]) { + return gateways[gwName].gatewayArn; + } + + // Check HTTP gateways (imperatively created for A/B testing) + const httpGateways = deployedResources?.httpGateways; + if (httpGateways && gwName && httpGateways[gwName]) { + return httpGateways[gwName].gatewayArn; + } + + return ref; +} + +function resolveEvalConfig( + config: + | { onlineEvaluationConfigArn: string } + | { perVariantOnlineEvaluationConfig: { treatmentName: 'C' | 'T1'; onlineEvaluationConfigArn: string }[] }, + deployedResources?: DeployedResourceState +): ABTestEvaluationConfig { + if ('perVariantOnlineEvaluationConfig' in config) { + // Per-variant eval config — resolve each ARN + return { + perVariantOnlineEvaluationConfig: config.perVariantOnlineEvaluationConfig.map(pv => ({ + name: pv.treatmentName, + onlineEvaluationConfigArn: resolveOnlineEvalArn(pv.onlineEvaluationConfigArn, deployedResources), + })), + }; + } + + const ref = config.onlineEvaluationConfigArn; + return { onlineEvaluationConfigArn: resolveOnlineEvalArn(ref, deployedResources) }; +} + +function resolveOnlineEvalArn(ref: string, deployedResources?: DeployedResourceState): string { + if (ref.startsWith('arn:')) return ref; + + const configs = deployedResources?.onlineEvalConfigs; + if (configs?.[ref]) { + return configs[ref].onlineEvaluationConfigArn; + } + + return ref; +} + +// ============================================================================ +// IAM Role Management +// ============================================================================ + +/** + * Generate a project-scoped role name following the CDK pattern: + * AgentCore-{ProjectName}-ABTest{TestName}-{Hash} + */ +function generateRoleName(projectName: string, testName: string): string { + // Deterministic hash so retries produce the same role name (avoids orphaned roles) + const hash = createHash('sha256').update(`${projectName}:${testName}`).digest('hex').slice(0, 8); + const base = `AgentCore-${projectName}-ABTest${testName}`; + // IAM role names max 64 chars + return `${base.slice(0, 55)}-${hash}`; +} + +/** + * Extract role name from ARN: arn:aws:iam::123456789012:role/RoleName → RoleName + */ +function roleNameFromArn(roleArn: string): string { + const parts = roleArn.split('/'); + return parts[parts.length - 1] ?? roleArn; +} + +interface CreateABTestRoleOptions { + region: string; + projectName: string; + testName: string; + gatewayArn: string; +} + +async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise { + const { region, projectName, testName, gatewayArn } = options; + const credentials = getCredentialProvider(); + const iamClient = new IAMClient({ region, credentials }); + + // Extract account ID from gateway ARN (arn:aws:bedrock-agentcore:REGION:ACCOUNT:gateway/ID) + const accountId = gatewayArn.split(':')[4] ?? '*'; + + const roleName = generateRoleName(projectName, testName); + + const trustPolicy = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { Service: 'bedrock-agentcore.amazonaws.com' }, + Action: 'sts:AssumeRole', + Condition: { + StringEquals: { 'aws:SourceAccount': accountId }, + ArnLike: { 'aws:SourceArn': `${arnPrefix(region)}:bedrock-agentcore:*:${accountId}:ab-test/*` }, + }, + }, + ], + }); + + let roleArn: string; + let _needsPropagationWait = false; + + try { + const createResult = await iamClient.send( + new CreateRoleCommand({ + RoleName: roleName, + AssumeRolePolicyDocument: trustPolicy, + Description: `Auto-created execution role for AgentCore AB test: ${testName}`, + Tags: [ + { Key: 'agentcore:created-by', Value: 'agentcore-cli' }, + { Key: 'agentcore:project-name', Value: projectName }, + { Key: 'agentcore:ab-test-name', Value: testName }, + ], + }) + ); + + roleArn = createResult.Role?.Arn ?? ''; + if (!roleArn) { + throw new Error(`IAM CreateRole succeeded but returned no role ARN for "${roleName}"`); + } + _needsPropagationWait = true; + } catch (err: unknown) { + // Handle retry after a previous failed deploy left the role behind + const errName = (err as { name?: string }).name; + if (errName === 'EntityAlreadyExistsException') { + // IAM role already exists — reuse it + const existing = await iamClient.send(new GetRoleCommand({ RoleName: roleName })); + roleArn = existing.Role?.Arn ?? ''; + if (!roleArn) { + throw new Error(`Role "${roleName}" already exists but ARN could not be retrieved`); + } + } else { + throw err; + } + } + + const policy = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Sid: 'AgentCoreResources', + Effect: 'Allow', + Action: [ + 'bedrock-agentcore:GetGateway', + 'bedrock-agentcore:GetGatewayTarget', + 'bedrock-agentcore:ListGatewayTargets', + 'bedrock-agentcore:CreateGatewayRule', + 'bedrock-agentcore:UpdateGatewayRule', + 'bedrock-agentcore:GetGatewayRule', + 'bedrock-agentcore:DeleteGatewayRule', + 'bedrock-agentcore:ListGatewayRules', + 'bedrock-agentcore:GetOnlineEvaluationConfig', + 'bedrock-agentcore:GetEvaluator', + 'bedrock-agentcore:GetConfigurationBundle', + 'bedrock-agentcore:GetConfigurationBundleVersion', + 'bedrock-agentcore:ListConfigurationBundleVersions', + ], + Resource: `${arnPrefix(region)}:bedrock-agentcore:*:${accountId}:*`, + Condition: { StringEquals: { 'aws:ResourceAccount': accountId } }, + }, + { + Sid: 'CloudWatchLogsDescribe', + Effect: 'Allow', + Action: ['logs:DescribeLogGroups'], + Resource: '*', + }, + { + Sid: 'CloudWatchLogs', + Effect: 'Allow', + Action: [ + 'logs:DescribeIndexPolicies', + 'logs:PutIndexPolicy', + 'logs:StartQuery', + 'logs:GetQueryResults', + 'logs:StopQuery', + 'logs:FilterLogEvents', + 'logs:GetLogEvents', + ], + Resource: [ + `${arnPrefix(region)}:logs:*:${accountId}:log-group:/aws/bedrock-agentcore/evaluations/*`, + `${arnPrefix(region)}:logs:*:${accountId}:log-group:aws/spans`, + `${arnPrefix(region)}:logs:*:${accountId}:log-group:aws/spans:*`, + ], + }, + ], + }); + + // Re-apply the inline policy (idempotent — covers both new and recovered roles) + await iamClient.send( + new PutRolePolicyCommand({ + RoleName: roleName, + PolicyName: AB_TEST_ROLE_POLICY_NAME, + PolicyDocument: policy, + }) + ); + + // Always wait for IAM policy propagation — both new roles and policy updates on existing roles + await new Promise(resolve => setTimeout(resolve, 15_000)); + + return roleArn; +} + +async function deleteABTestRole(region: string, roleArn: string): Promise { + const credentials = getCredentialProvider(); + const iamClient = new IAMClient({ region, credentials }); + const roleName = roleNameFromArn(roleArn); + + try { + // Must delete inline policies before deleting the role + await iamClient.send( + new DeleteRolePolicyCommand({ + RoleName: roleName, + PolicyName: AB_TEST_ROLE_POLICY_NAME, + }) + ); + } catch { + // Policy may not exist + } + + try { + await iamClient.send(new DeleteRoleCommand({ RoleName: roleName })); + } catch { + // Role may already be deleted or in use — best effort + } +} diff --git a/src/cli/operations/deploy/post-deploy-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts new file mode 100644 index 000000000..5318c54b1 --- /dev/null +++ b/src/cli/operations/deploy/post-deploy-config-bundles.ts @@ -0,0 +1,348 @@ +import type { AgentCoreProjectSpec, ConfigBundleDeployedState, DeployedState } from '../../../schema'; +import { + createConfigurationBundle, + deleteConfigurationBundle, + getConfigurationBundleVersion, + listConfigurationBundleVersions, + listConfigurationBundles, + updateConfigurationBundle, +} from '../../aws/agentcore-config-bundles'; +import type { ComponentConfigurationMap } from '../../aws/agentcore-config-bundles'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface SetupConfigBundlesOptions { + region: string; + projectSpec: AgentCoreProjectSpec; + /** Existing config bundle deployed state (from deployed-state.json) */ + existingBundles?: Record; +} + +export interface ConfigBundleSetupResult { + bundleName: string; + status: 'created' | 'updated' | 'deleted' | 'skipped' | 'error'; + bundleId?: string; + bundleArn?: string; + versionId?: string; + error?: string; +} + +export interface SetupConfigBundlesResult { + results: ConfigBundleSetupResult[]; + /** Deployed state entries for config bundles (to merge into deployed-state.json) */ + configBundles: Record; + hasErrors: boolean; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Create, update, or delete configuration bundles post-deploy. + * + * Pattern: + * 1. For each configBundle in project spec → create or update + * 2. For each bundle in deployed-state but NOT in project spec → delete (reconciliation) + * 3. Return updated deployed state entries + */ +export async function setupConfigBundles(options: SetupConfigBundlesOptions): Promise { + const { region, projectSpec, existingBundles } = options; + const results: ConfigBundleSetupResult[] = []; + const configBundles: Record = {}; + + const specBundleNames = new Set((projectSpec.configBundles ?? []).map(b => b.name)); + const projectName = projectSpec.name; + + // Create or update bundles from the spec + for (const bundleSpec of projectSpec.configBundles ?? []) { + // Prepend project name to the API-side bundle name (no separator for config bundles) + const apiBundleName = `${projectName}${bundleSpec.name}`; + + try { + // Try to update if we have an existing bundle ID + const existingBundle = existingBundles?.[bundleSpec.name]; + let updated = false; + + if (existingBundle) { + try { + // Fetch the exact version we know about — avoids branch-not-found errors + const current = await getConfigurationBundleVersion({ + region, + bundleId: existingBundle.bundleId, + versionId: existingBundle.versionId, + }); + const componentsChanged = !deepEqual(current.components, bundleSpec.components); + const descriptionChanged = (bundleSpec.description ?? undefined) !== (current.description ?? undefined); + + if (!componentsChanged && !descriptionChanged) { + // Nothing changed — skip the update, preserve existing state + configBundles[bundleSpec.name] = { + bundleId: existingBundle.bundleId, + bundleArn: existingBundle.bundleArn, + versionId: existingBundle.versionId, + }; + results.push({ + bundleName: bundleSpec.name, + status: 'skipped', + bundleId: existingBundle.bundleId, + bundleArn: existingBundle.bundleArn, + versionId: existingBundle.versionId, + }); + updated = true; + } else { + // Use the branch from the spec, or fall back to whatever branch the API has + const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'mainline'; + const result = await updateConfigurationBundle({ + region, + bundleId: existingBundle.bundleId, + description: bundleSpec.description, + components: bundleSpec.components as ComponentConfigurationMap, + parentVersionIds: [current.versionId], + branchName: effectiveBranch, + commitMessage: bundleSpec.commitMessage ?? `Update ${bundleSpec.name}`, + }); + + configBundles[bundleSpec.name] = { + bundleId: result.bundleId, + bundleArn: result.bundleArn, + versionId: result.versionId, + }; + + results.push({ + bundleName: bundleSpec.name, + status: 'updated', + bundleId: result.bundleId, + bundleArn: result.bundleArn, + versionId: result.versionId, + }); + updated = true; + } + } catch (updateErr) { + // If bundle or branch not found, fall through to find-by-name or create + const msg = updateErr instanceof Error ? updateErr.message : String(updateErr); + if (!msg.includes('404') && !msg.includes('not found')) throw updateErr; + } + } + + if (!updated) { + // Try to find by name via list (handles re-creation after state loss) + const existingByName = await findBundleByName(region, apiBundleName); + + if (existingByName) { + // Fetch versions and pick the newest — avoids branch-not-found errors from getConfigurationBundle + const versions = await listConfigurationBundleVersions({ + region, + bundleId: existingByName.bundleId, + }); + const sorted = [...versions.versions].sort((a, b) => Number(b.versionCreatedAt) - Number(a.versionCreatedAt)); + const latestVersionId = sorted[0]?.versionId; + if (!latestVersionId) throw new Error(`No versions found for bundle ${bundleSpec.name}`); + const current = await getConfigurationBundleVersion({ + region, + bundleId: existingByName.bundleId, + versionId: latestVersionId, + }); + const componentsChanged = !deepEqual(current.components, bundleSpec.components); + const descriptionChanged = (bundleSpec.description ?? undefined) !== (current.description ?? undefined); + + if (!componentsChanged && !descriptionChanged) { + configBundles[bundleSpec.name] = { + bundleId: existingByName.bundleId, + bundleArn: current.bundleArn, + versionId: current.versionId, + }; + results.push({ + bundleName: bundleSpec.name, + status: 'skipped', + bundleId: existingByName.bundleId, + bundleArn: current.bundleArn, + versionId: current.versionId, + }); + } else { + const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'mainline'; + const result = await updateConfigurationBundle({ + region, + bundleId: existingByName.bundleId, + description: bundleSpec.description, + components: bundleSpec.components as ComponentConfigurationMap, + parentVersionIds: [current.versionId], + branchName: effectiveBranch, + commitMessage: bundleSpec.commitMessage ?? `Update ${bundleSpec.name}`, + }); + + configBundles[bundleSpec.name] = { + bundleId: result.bundleId, + bundleArn: result.bundleArn, + versionId: result.versionId, + }; + + results.push({ + bundleName: bundleSpec.name, + status: 'updated', + bundleId: result.bundleId, + bundleArn: result.bundleArn, + versionId: result.versionId, + }); + } + } else { + // Create new — omit branchName if not in spec so the API uses its default + const result = await createConfigurationBundle({ + region, + bundleName: apiBundleName, + description: bundleSpec.description, + components: bundleSpec.components as ComponentConfigurationMap, + branchName: bundleSpec.branchName, + commitMessage: bundleSpec.commitMessage ?? `Create ${bundleSpec.name}`, + }); + + configBundles[bundleSpec.name] = { + bundleId: result.bundleId, + bundleArn: result.bundleArn, + versionId: result.versionId, + }; + + results.push({ + bundleName: bundleSpec.name, + status: 'created', + bundleId: result.bundleId, + bundleArn: result.bundleArn, + versionId: result.versionId, + }); + } + } + } catch (err) { + results.push({ + bundleName: bundleSpec.name, + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Delete orphaned bundles (in deployed-state but removed from spec) + if (existingBundles) { + for (const [bundleName, bundleState] of Object.entries(existingBundles)) { + if (!specBundleNames.has(bundleName)) { + try { + await deleteConfigurationBundle({ + region, + bundleId: bundleState.bundleId, + }); + + results.push({ + bundleName, + status: 'deleted', + }); + } catch (err) { + results.push({ + bundleName, + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + } + + return { + results, + configBundles, + hasErrors: results.some(r => r.status === 'error'), + }; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +async function findBundleByName(region: string, bundleName: string): Promise<{ bundleId: string } | undefined> { + try { + const result = await listConfigurationBundles({ region, maxResults: 100 }); + return result.bundles.find(b => b.bundleName === bundleName); + } catch { + return undefined; + } +} + +/** Key-order-independent deep-equal for JSON-serializable objects. */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null || typeof a !== typeof b) return false; + if (typeof a !== 'object') return false; + + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false; + return a.every((item, i) => deepEqual(item, b[i])); + } + + const aObj = a as Record; + const bObj = b as Record; + const aKeys = Object.keys(aObj); + const bKeys = Object.keys(bObj); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every(key => key in bObj && deepEqual(aObj[key], bObj[key])); +} + +// ============================================================================ +// Component Key Resolution +// ============================================================================ + +/** + * Resolve placeholder component keys (e.g., {{runtime:name}}, {{gateway:name}}) + * to actual ARNs from deployed state. + */ +export function resolveConfigBundleComponentKeys( + projectSpec: AgentCoreProjectSpec, + deployedState: DeployedState, + targetName: string +): AgentCoreProjectSpec { + const resources = deployedState.targets?.[targetName]?.resources; + if (!resources) return projectSpec; + + const resolvedBundles = (projectSpec.configBundles ?? []).map(bundle => { + const resolvedComponents: Record }> = {}; + + for (const [key, value] of Object.entries(bundle.components ?? {})) { + const resolvedKey = resolveComponentKey(key, resources); + resolvedComponents[resolvedKey] = value; + } + + return { ...bundle, components: resolvedComponents }; + }); + + return { ...projectSpec, configBundles: resolvedBundles }; +} + +function resolveComponentKey( + key: string, + resources: NonNullable +): string { + if (key.startsWith('arn:')) return key; + + const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(key); + if (gwMatch) { + const gwName = gwMatch[1]!; + const httpGw = resources.httpGateways?.[gwName]; + if (httpGw) return httpGw.gatewayArn; + const mcpGw = resources.mcp?.gateways?.[gwName]; + if (mcpGw) return mcpGw.gatewayArn; + throw new Error( + `Config bundle references gateway "${gwName}" but it was not found in deployed resources. Ensure the gateway is defined in agentcore.json and deploys successfully.` + ); + } + + const rtMatch = /^\{\{runtime:(.+)\}\}$/.exec(key); + if (rtMatch) { + const rtName = rtMatch[1]!; + const rt = resources.runtimes?.[rtName]; + if (rt) return rt.runtimeArn; + throw new Error( + `Config bundle references runtime "${rtName}" but it was not found in deployed resources. Ensure the runtime is defined in agentcore.json and deploys successfully.` + ); + } + + return key; +} diff --git a/src/cli/operations/deploy/post-deploy-http-gateways.ts b/src/cli/operations/deploy/post-deploy-http-gateways.ts new file mode 100644 index 000000000..d59a62bdf --- /dev/null +++ b/src/cli/operations/deploy/post-deploy-http-gateways.ts @@ -0,0 +1,652 @@ +import type { AgentCoreProjectSpec, DeployedResourceState, HttpGatewayDeployedState } from '../../../schema'; +import { getCredentialProvider } from '../../aws/account'; +import { + createHttpGateway, + createHttpGatewayTarget, + deleteHttpGateway, + deleteHttpGatewayTarget, + getHttpGatewayTarget, + listAllHttpGateways, + listHttpGatewayTargets, + waitForGatewayReady, + waitForTargetReady, +} from '../../aws/agentcore-http-gateways'; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + GetRoleCommand, + IAMClient, + PutRolePolicyCommand, +} from '@aws-sdk/client-iam'; +import { createHash } from 'node:crypto'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface SetupHttpGatewaysOptions { + region: string; + projectName: string; + projectSpec: AgentCoreProjectSpec; + existingHttpGateways?: Record; + deployedResources?: DeployedResourceState; +} + +export interface HttpGatewaySetupResult { + gatewayName: string; + status: 'created' | 'skipped' | 'deleted' | 'error'; + gatewayId?: string; + gatewayArn?: string; + error?: string; +} + +export interface SetupHttpGatewaysResult { + results: HttpGatewaySetupResult[]; + httpGateways: Record; + hasErrors: boolean; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const HTTP_GATEWAY_ROLE_POLICY_NAME = 'HttpGatewayExecutionPolicy'; + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Create or delete HTTP gateways post-deploy. + * + * Pattern: + * 1. For each httpGateway in project spec -> resolve runtime ARN, create or skip + * 2. For each httpGateway in deployed-state but NOT in project spec -> delete (reconciliation) + * 3. Return updated deployed state entries + */ +export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Promise { + const { region, projectName, projectSpec, existingHttpGateways, deployedResources } = options; + const results: HttpGatewaySetupResult[] = []; + const httpGateways: Record = {}; + + // Defensive: Zod .default([]) only fires on undefined, not null. + // If someone has "httpGateways": null in their JSON, it passes through as null. + const httpGatewaySpecs = projectSpec.httpGateways ?? []; + + // Create or skip gateways from the spec + for (const gwSpec of httpGatewaySpecs) { + let resolvedRoleArn: string | undefined; + let roleCreatedByCli = false; + try { + const existingGateway = existingHttpGateways?.[gwSpec.name]; + + if (existingGateway) { + // Already deployed + + // Create or update targets from httpGateways[].targets (for target-based AB testing) + if (gwSpec.targets && gwSpec.targets.length > 0) { + // List existing targets to avoid unnecessary create calls + const existingTargetsByName = new Map(); + try { + const existingTargets = await listHttpGatewayTargets({ + region, + gatewayId: existingGateway.gatewayId, + }); + for (const t of existingTargets.targets) { + existingTargetsByName.set(t.name, { targetId: t.targetId }); + } + } catch { + // If list fails, fall through and let create handle 409s + } + + for (const tgt of gwSpec.targets) { + const existingTarget = existingTargetsByName.get(`${projectName}-${tgt.name}`); + if (existingTarget) { + // Target exists by name — check if qualifier matches + try { + const targetDetails = await getHttpGatewayTarget({ + region, + gatewayId: existingGateway.gatewayId, + targetId: existingTarget.targetId, + }); + const httpConfig = ( + targetDetails.targetConfiguration as + | { + http?: { + agentcoreRuntime?: { qualifier?: string }; + runtimeTargetConfiguration?: { qualifier?: string }; + }; + } + | undefined + )?.http; + const existingQualifier = + httpConfig?.agentcoreRuntime?.qualifier ?? httpConfig?.runtimeTargetConfiguration?.qualifier; + const specQualifier = tgt.qualifier ?? 'DEFAULT'; + if (existingQualifier === specQualifier) { + // Qualifier matches — skip + continue; + } + // Qualifier differs — delete old target and recreate + await deleteHttpGatewayTarget({ + region, + gatewayId: existingGateway.gatewayId, + targetId: existingTarget.targetId, + }); + } catch { + // If get/delete fails, fall through to create which will handle conflicts + } + } + try { + const tgtRuntime = deployedResources?.runtimes?.[tgt.runtimeRef]; + if (!tgtRuntime) continue; + const tgtResult = await createHttpGatewayTarget({ + region, + gatewayId: existingGateway.gatewayId, + targetName: `${projectName}-${tgt.name}`, + runtimeArn: tgtRuntime.runtimeArn, + qualifier: tgt.qualifier, + }); + await waitForTargetReady({ + region, + gatewayId: existingGateway.gatewayId, + targetId: tgtResult.targetId, + }); + } catch (tgtErr) { + if (tgtErr instanceof Error && tgtErr.message.includes('409')) continue; + // Non-fatal + } + } + } + + httpGateways[gwSpec.name] = existingGateway; + results.push({ + gatewayName: gwSpec.name, + status: 'skipped', + gatewayId: existingGateway.gatewayId, + gatewayArn: existingGateway.gatewayArn, + }); + continue; + } + + // Try to find by name via list (handles re-creation after state loss) + const prefixedGatewayName = `${projectName}-${gwSpec.name}`; + const existingByName = await findHttpGatewayByName(region, prefixedGatewayName); + if (existingByName) { + console.warn( + `Warning: HTTP gateway "${gwSpec.name}" found by name but local state was lost. Target and role state may be incomplete — consider re-deploying.` + ); + httpGateways[gwSpec.name] = { + gatewayId: existingByName.gatewayId, + gatewayArn: existingByName.gatewayArn, + // targetId, roleArn, roleCreatedByCli unknown after state-loss recovery + }; + results.push({ + gatewayName: gwSpec.name, + status: 'skipped', + gatewayId: existingByName.gatewayId, + gatewayArn: existingByName.gatewayArn, + }); + continue; + } + + // Migration fallback: try unprefixed name for pre-PR gateways (Comment 3 fix) + const existingByLegacyName = await findHttpGatewayByName(region, gwSpec.name); + if (existingByLegacyName) { + console.warn( + `Warning: HTTP gateway "${gwSpec.name}" was found using its pre-migration name. ` + + `This CLI version uses the naming convention "${prefixedGatewayName}". ` + + `The gateway has been recovered from state loss. ` + + `You may want to rename "${gwSpec.name}" to "${prefixedGatewayName}" on AWS to match the new convention.` + ); + httpGateways[gwSpec.name] = { + gatewayId: existingByLegacyName.gatewayId, + gatewayArn: existingByLegacyName.gatewayArn, + // targetId, roleArn, roleCreatedByCli unknown after state-loss recovery + }; + results.push({ + gatewayName: gwSpec.name, + status: 'skipped', + gatewayId: existingByLegacyName.gatewayId, + gatewayArn: existingByLegacyName.gatewayArn, + }); + continue; + } + + // Resolve runtime ARN from deployed state + const runtimeState = deployedResources?.runtimes?.[gwSpec.runtimeRef]; + if (!runtimeState) { + results.push({ + gatewayName: gwSpec.name, + status: 'error', + error: `Runtime "${gwSpec.runtimeRef}" not found in deployed resources. Deploy the runtime before creating an HTTP gateway.`, + }); + continue; + } + const runtimeArn = runtimeState.runtimeArn; + if (gwSpec.roleArn) { + resolvedRoleArn = gwSpec.roleArn; + } else { + resolvedRoleArn = await getOrCreateHttpGatewayRole({ + region, + projectName, + gatewayName: gwSpec.name, + runtimeArn, + }); + roleCreatedByCli = true; + } + + // Create gateway and wait for it to become READY before adding targets + // Creating HTTP gateway for runtime + const createResult = await createHttpGateway({ + region, + name: `${projectName}-${gwSpec.name}`, + roleArn: resolvedRoleArn, + }); + + const readyGateway = await waitForGatewayReady({ + region, + gatewayId: createResult.gatewayId, + }); + + // Create target pointing to the runtime + let targetId: string | undefined; + try { + const targetResult = await createHttpGatewayTarget({ + region, + gatewayId: createResult.gatewayId, + targetName: `${projectName}-${gwSpec.runtimeRef}`, + runtimeArn, + }); + + targetId = targetResult.targetId; + + // Wait for target to become ready + // Waiting for gateway target to become ready + await waitForTargetReady({ + region, + gatewayId: createResult.gatewayId, + targetId: targetResult.targetId, + }); + } catch (targetErr) { + // Rollback: delete target (if created), wait for deletion, then delete gateway + try { + if (targetId) { + await deleteHttpGatewayTarget({ region, gatewayId: createResult.gatewayId, targetId }); + } + } catch { + // Best-effort target cleanup + } + try { + await deleteHttpGateway({ region, gatewayId: createResult.gatewayId }); + } catch { + // Best-effort gateway rollback + } + + // Always clean up auto-created role on target failure, regardless of gateway rollback result + if (roleCreatedByCli && resolvedRoleArn) { + try { + await deleteHttpGatewayRole(region, resolvedRoleArn); + } catch { + // Best-effort role cleanup + } + } + + results.push({ + gatewayName: gwSpec.name, + status: 'error', + error: `Target creation failed, gateway rolled back: ${targetErr instanceof Error ? targetErr.message : String(targetErr)}`, + }); + continue; + } + + // Create additional targets from httpGateways[].targets (for target-based AB testing) + if (gwSpec.targets && gwSpec.targets.length > 0) { + for (const tgt of gwSpec.targets) { + try { + const tgtRuntime = deployedResources?.runtimes?.[tgt.runtimeRef]; + if (!tgtRuntime) { + // Runtime not deployed, skip this target + continue; + } + const tgtResult = await createHttpGatewayTarget({ + region, + gatewayId: createResult.gatewayId, + targetName: `${projectName}-${tgt.name}`, + runtimeArn: tgtRuntime.runtimeArn, + qualifier: tgt.qualifier, + }); + await waitForTargetReady({ + region, + gatewayId: createResult.gatewayId, + targetId: tgtResult.targetId, + }); + } catch (tgtErr) { + // 409 = already exists, skip + if (tgtErr instanceof Error && tgtErr.message.includes('409')) continue; + // Non-fatal: log but continue + } + } + } + + httpGateways[gwSpec.name] = { + gatewayId: createResult.gatewayId, + gatewayArn: createResult.gatewayArn, + gatewayUrl: readyGateway.gatewayUrl, + targetId, + roleArn: resolvedRoleArn, + roleCreatedByCli, + }; + + results.push({ + gatewayName: gwSpec.name, + status: 'created', + gatewayId: createResult.gatewayId, + gatewayArn: createResult.gatewayArn, + }); + } catch (err) { + // If we auto-created a role, clean it up on failure + if (roleCreatedByCli && resolvedRoleArn) { + try { + await deleteHttpGatewayRole(region, resolvedRoleArn); + } catch { + // Best-effort role cleanup + } + } + results.push({ + gatewayName: gwSpec.name, + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Orphaned gateways are deleted by deleteOrphanedHttpGateways() which runs + // as a separate pre-pass. No deletion loop here. + + return { + results, + httpGateways, + hasErrors: results.some(r => r.status === 'error'), + }; +} + +// ============================================================================ +// Shared Gateway Deletion +// ============================================================================ + +/** + * Delete an HTTP gateway and all its targets. Best-effort — target failures + * are warned but don't prevent gateway deletion attempt. + * + * Order: targets → gateway → role + */ +export async function deleteHttpGatewayWithTargets(options: { + region: string; + gatewayId: string; + gatewayName: string; + knownTargetId?: string; + roleArn?: string; + roleCreatedByCli?: boolean; +}): Promise<{ success: boolean; error?: string }> { + const { region, gatewayId, gatewayName, knownTargetId, roleArn, roleCreatedByCli } = options; + + const targetIds: string[] = []; + if (knownTargetId) { + targetIds.push(knownTargetId); + } + try { + const targets = await listHttpGatewayTargets({ region, gatewayId, maxResults: 100 }); + for (const t of targets.targets) { + if (!targetIds.includes(t.targetId)) { + targetIds.push(t.targetId); + } + } + } catch { + // Best-effort — proceed with whatever IDs we have + } + + for (const targetId of targetIds) { + try { + await deleteHttpGatewayTarget({ region, gatewayId, targetId }); + } catch (err) { + console.warn( + `Warning: Failed to delete target ${targetId} on gateway "${gatewayName}": ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + const deleteResult = await deleteHttpGateway({ region, gatewayId }); + if (!deleteResult.success) { + return { success: false, error: deleteResult.error }; + } + + if (roleCreatedByCli && roleArn) { + try { + await deleteHttpGatewayRole(region, roleArn); + } catch { + // Best-effort role cleanup + } + } + + return { success: true }; +} + +/** + * Delete orphaned HTTP gateways (in deployed-state but removed from spec). + * Call before setupHttpGateways. + */ +export async function deleteOrphanedHttpGateways(options: { + region: string; + projectSpec: AgentCoreProjectSpec; + existingHttpGateways?: Record; +}): Promise<{ results: HttpGatewaySetupResult[]; hasErrors: boolean }> { + const { region, projectSpec, existingHttpGateways } = options; + if (!existingHttpGateways) return { results: [], hasErrors: false }; + + const specGatewayNames = new Set((projectSpec.httpGateways ?? []).map(g => g.name)); + const results: HttpGatewaySetupResult[] = []; + + for (const [gwName, gwState] of Object.entries(existingHttpGateways)) { + if (!specGatewayNames.has(gwName)) { + try { + const result = await deleteHttpGatewayWithTargets({ + region, + gatewayId: gwState.gatewayId, + gatewayName: gwName, + knownTargetId: gwState.targetId, + roleArn: gwState.roleArn, + roleCreatedByCli: gwState.roleCreatedByCli, + }); + + results.push({ + gatewayName: gwName, + status: result.success ? 'deleted' : 'error', + error: result.error, + }); + } catch (err) { + results.push({ + gatewayName: gwName, + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + return { + results, + hasErrors: results.some(r => r.status === 'error'), + }; +} + +// ============================================================================ +// Gateway Trace Delivery +// ============================================================================ + +// ============================================================================ +// Helpers +// ============================================================================ + +async function findHttpGatewayByName( + region: string, + name: string +): Promise<{ gatewayId: string; gatewayArn: string } | undefined> { + try { + const gateways = await listAllHttpGateways({ region }); + return gateways.find(gw => gw.name === name); + } catch (err) { + console.warn( + `Warning: Could not list HTTP gateways to check for existing "${name}": ${err instanceof Error ? err.message : String(err)}` + ); + return undefined; + } +} + +// ============================================================================ +// IAM Role Management +// ============================================================================ + +/** + * Generate a project-scoped role name following the CDK pattern: + * AgentCore-{ProjectName}-HttpGw{GatewayName}-{Hash} + */ +function generateRoleName(projectName: string, gatewayName: string): string { + const base = `AgentCore-${projectName}-HttpGw${gatewayName}`; + // Use deterministic hash so retries produce the same role name + const hash = createHash('sha256').update(`${projectName}:${gatewayName}`).digest('hex').slice(0, 8); + // IAM role names max 64 chars + return `${base.slice(0, 55)}-${hash}`; +} + +/** + * Extract role name from ARN: arn:aws:iam::123456789012:role/RoleName -> RoleName + */ +function roleNameFromArn(roleArn: string): string { + const parts = roleArn.split('/'); + return parts[parts.length - 1] ?? roleArn; +} + +interface CreateHttpGatewayRoleOptions { + region: string; + projectName: string; + gatewayName: string; + runtimeArn: string; +} + +async function getOrCreateHttpGatewayRole(options: CreateHttpGatewayRoleOptions): Promise { + const { region, projectName, gatewayName } = options; + const credentials = getCredentialProvider(); + const iamClient = new IAMClient({ region, credentials }); + + const roleName = generateRoleName(projectName, gatewayName); + + const trustPolicy = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { Service: 'bedrock-agentcore.amazonaws.com' }, + Action: 'sts:AssumeRole', + }, + ], + }); + + const policy = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Sid: 'InvokeRuntimeStatement', + Effect: 'Allow', + Action: [ + 'bedrock-agentcore:InvokeRuntime', + 'bedrock-agentcore:InvokeAgent', + 'bedrock-agentcore:InvokeAgentRuntime', + ], + // Resource must be '*' because the gateway service invokes runtimes using + // a resource identifier that doesn't match the deployed runtime ARN format. + // This matches the A/B testing guide's gateway role policy. + Resource: '*', + }, + ], + }); + + let roleArn: string; + let needsPropagationWait = false; + + try { + const createResult = await iamClient.send( + new CreateRoleCommand({ + RoleName: roleName, + AssumeRolePolicyDocument: trustPolicy, + Description: `Auto-created execution role for AgentCore HTTP gateway: ${gatewayName}`, + Tags: [ + { Key: 'agentcore:created-by', Value: 'agentcore-cli' }, + { Key: 'agentcore:project-name', Value: projectName }, + { Key: 'agentcore:http-gateway-name', Value: gatewayName }, + ], + }) + ); + + roleArn = createResult.Role?.Arn ?? ''; + if (!roleArn) { + throw new Error(`IAM CreateRole succeeded but returned no role ARN for "${roleName}"`); + } + needsPropagationWait = true; + } catch (err: unknown) { + // Handle retry after a previous failed deploy left the role behind + const errName = (err as { name?: string }).name; + if (errName === 'EntityAlreadyExistsException') { + // IAM role already exists — reusing + const existing = await iamClient.send(new GetRoleCommand({ RoleName: roleName })); + roleArn = existing.Role?.Arn ?? ''; + if (!roleArn) { + throw new Error(`Role "${roleName}" already exists but ARN could not be retrieved`); + } + } else { + throw new Error( + `Failed to create IAM role "${roleName}" for HTTP gateway "${gatewayName}": ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + // Re-apply the inline policy (idempotent — covers both new and recovered roles) + await iamClient.send( + new PutRolePolicyCommand({ + RoleName: roleName, + PolicyName: HTTP_GATEWAY_ROLE_POLICY_NAME, + PolicyDocument: policy, + }) + ); + + if (needsPropagationWait) { + // Waiting for IAM role propagation (~15s) + await new Promise(resolve => setTimeout(resolve, 15_000)); + } + + return roleArn; +} + +export async function deleteHttpGatewayRole(region: string, roleArn: string): Promise { + const credentials = getCredentialProvider(); + const iamClient = new IAMClient({ region, credentials }); + const roleName = roleNameFromArn(roleArn); + + try { + // Must delete inline policies before deleting the role + await iamClient.send( + new DeleteRolePolicyCommand({ + RoleName: roleName, + PolicyName: HTTP_GATEWAY_ROLE_POLICY_NAME, + }) + ); + } catch { + // Policy may not exist + } + + try { + await iamClient.send(new DeleteRoleCommand({ RoleName: roleName })); + } catch { + // Role may already be deleted or in use -- best effort + } +} diff --git a/src/cli/operations/deploy/post-deploy-observability.ts b/src/cli/operations/deploy/post-deploy-observability.ts index 295392629..0616a65dc 100644 --- a/src/cli/operations/deploy/post-deploy-observability.ts +++ b/src/cli/operations/deploy/post-deploy-observability.ts @@ -1,4 +1,4 @@ -import { readCliConfig } from '../../../lib/schemas/io/cli-config'; +import { readGlobalConfigSync } from '../../../lib/schemas/io/global-config'; import { enableTransactionSearch } from '../../aws/transaction-search'; export interface TransactionSearchSetupOptions { @@ -31,7 +31,7 @@ export async function setupTransactionSearch( return { success: true }; } - const config = readCliConfig(); + const config = readGlobalConfigSync(); if (config.disableTransactionSearch) { return { success: true }; } diff --git a/src/cli/operations/deploy/post-deploy-online-evals.ts b/src/cli/operations/deploy/post-deploy-online-evals.ts new file mode 100644 index 000000000..d1012898d --- /dev/null +++ b/src/cli/operations/deploy/post-deploy-online-evals.ts @@ -0,0 +1,80 @@ +import type { OnlineEvalDeployedState } from '../../../schema/schemas/deployed-state'; +import type { OnlineEvalConfig } from '../../../schema/schemas/primitives/online-eval-config'; +import { updateOnlineEvalExecutionStatus } from '../../aws/agentcore-control'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface EnableOnlineEvalsOptions { + region: string; + onlineEvalConfigs: OnlineEvalConfig[]; + deployedOnlineEvalConfigs: Record; +} + +export interface OnlineEvalEnableResult { + configName: string; + status: 'enabled' | 'skipped' | 'error'; + error?: string; +} + +export interface EnableOnlineEvalsResult { + results: OnlineEvalEnableResult[]; + hasErrors: boolean; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Enable online eval configs that have `enableOnCreate: true` in the project spec. + * + * CFN does not support EnableOnCreate on `AWS::BedrockAgentCore::OnlineEvaluationConfig`, + * so configs always deploy as DISABLED. This post-deploy step enables them via API. + * + * Callers should only pass newly deployed configs (not previously existing ones) to + * avoid re-enabling configs a customer intentionally disabled. + */ +export async function enableOnlineEvalConfigs(options: EnableOnlineEvalsOptions): Promise { + const { region, onlineEvalConfigs, deployedOnlineEvalConfigs } = options; + const results: OnlineEvalEnableResult[] = []; + + for (const config of onlineEvalConfigs) { + // Default enableOnCreate to true when not explicitly set + if (config.enableOnCreate === false) { + results.push({ configName: config.name, status: 'skipped' }); + continue; + } + + const deployed = deployedOnlineEvalConfigs[config.name]; + if (!deployed) { + results.push({ + configName: config.name, + status: 'error', + error: `Online eval config "${config.name}" not found in deployed state`, + }); + continue; + } + + try { + await updateOnlineEvalExecutionStatus({ + region, + onlineEvaluationConfigId: deployed.onlineEvaluationConfigId, + executionStatus: 'ENABLED', + }); + results.push({ configName: config.name, status: 'enabled' }); + } catch (err) { + results.push({ + configName: config.name, + status: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return { + results, + hasErrors: results.some(r => r.status === 'error'), + }; +} diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 4aa24e71b..ba423a088 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -59,6 +59,7 @@ export function formatError(err: unknown): string { * Returns the project context needed for subsequent steps. */ const MAX_RUNTIME_NAME_LENGTH = 48; +const MAX_GATEWAY_COMBINED_NAME_LENGTH = 48; export async function validateProject(): Promise { // Find the agentcore config directory, walking up from cwd if needed @@ -70,6 +71,7 @@ export async function validateProject(): Promise { cdkProject.validate(); const configIO = new ConfigIO({ baseDir: configRoot }); + const projectSpec = await configIO.readProjectSpec(); const awsTargets = await configIO.resolveAWSDeploymentTargets(); @@ -107,6 +109,9 @@ export async function validateProject(): Promise { // Validate runtime names don't exceed AWS limits validateRuntimeNames(projectSpec); + // Validate HTTP gateway names don't exceed AWS limits when combined with project name + validateHttpGatewayNames(projectSpec); + // Validate Container agents have Dockerfiles validateContainerAgents(projectSpec, configRoot); @@ -139,6 +144,36 @@ function validateRuntimeNames(projectSpec: AgentCoreProjectSpec): void { } } +/** + * Validates that combined HTTP gateway names (projectName-gatewayName) don't exceed AWS limits. + */ +function validateHttpGatewayNames(projectSpec: AgentCoreProjectSpec): void { + const projectName = projectSpec.name; + for (const gateway of projectSpec.httpGateways ?? []) { + const gwName = gateway.name; + if (gwName) { + const combinedName = `${projectName}-${gwName}`; + if (combinedName.length > MAX_GATEWAY_COMBINED_NAME_LENGTH) { + throw new Error( + `HTTP gateway name too long: "${combinedName}" (${combinedName.length} chars). ` + + `AWS limits gateway names to ${MAX_GATEWAY_COMBINED_NAME_LENGTH} characters. ` + + `Shorten the project name or gateway name in agentcore.json.` + ); + } + } + for (const target of gateway.targets ?? []) { + const combined = `${projectName}-${target.name}`; + if (combined.length > MAX_GATEWAY_COMBINED_NAME_LENGTH) { + const maxTargetLen = MAX_GATEWAY_COMBINED_NAME_LENGTH - projectName.length - 1; + throw new Error( + `HTTP gateway target "${target.name}" in gateway "${gwName}" would exceed the ${MAX_GATEWAY_COMBINED_NAME_LENGTH}-character AWS limit when prefixed with project name "${projectName}-" (total: ${combined.length} chars). ` + + `Shorten the target name to ${maxTargetLen} characters or fewer.` + ); + } + } + } +} + /** * Validates that Container agents have required Dockerfiles. */ diff --git a/src/cli/operations/deploy/teardown.ts b/src/cli/operations/deploy/teardown.ts index 28a8f326a..2e38f2576 100644 --- a/src/cli/operations/deploy/teardown.ts +++ b/src/cli/operations/deploy/teardown.ts @@ -1,8 +1,11 @@ import { CONFIG_DIR, ConfigIO } from '../../../lib'; import type { AwsDeploymentTarget } from '../../../schema'; import { withTargetRegion } from '../../aws'; +import { deleteConfigurationBundle } from '../../aws/agentcore-config-bundles'; import { CdkToolkitWrapper, silentIoHost } from '../../cdk/toolkit-lib'; import { type DiscoveredStack, findStack } from '../../cloudformation/stack-discovery'; +import { deleteOrphanedABTests } from './post-deploy-ab-tests'; +import { deleteOrphanedHttpGateways } from './post-deploy-http-gateways'; import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; import { existsSync } from 'fs'; import { join } from 'path'; @@ -111,6 +114,83 @@ export async function performStackTeardown(targetName: string): Promise dt.target.name === targetName); + + // Clean up imperatively-created resources before stack destruction. + // Ordering: AB tests first (they create rules on gateways), then gateways, then bundles. + // Delegates to the existing orphan-cleanup functions with an empty spec so everything + // is treated as orphaned — reuses stop/poll/delete/role-cleanup logic without duplication. + try { + const deployedState = await configIO.readDeployedState(); + const resources = deployedState.targets?.[targetName]?.resources; + + if (resources?.httpGateways || resources?.configBundles || resources?.abTests) { + let region = deployedTarget?.target.region; + if (!region) { + try { + const targets = await configIO.resolveAWSDeploymentTargets(); + const matchingTarget = targets.find(t => t.name === targetName); + region = matchingTarget?.region; + } catch { + // Can't resolve region + } + } + if (!region) { + console.warn('Warning: Could not determine region for resource cleanup — resources may need manual deletion'); + } + if (region) { + const projectSpec = await configIO.readProjectSpec(); + const emptySpec = { ...projectSpec, abTests: [], httpGateways: [] }; + + if (resources.abTests) { + const abResult = await deleteOrphanedABTests({ + region, + projectSpec: emptySpec, + existingABTests: resources.abTests, + }); + for (const r of abResult.results) { + if (r.status === 'deleted') { + console.log(`Deleted AB test "${r.testName}"`); + } else if (r.error) { + console.warn(`Warning: Failed to delete AB test "${r.testName}": ${r.error}`); + } + } + } + + if (resources.httpGateways) { + const gwResult = await deleteOrphanedHttpGateways({ + region, + projectSpec: emptySpec, + existingHttpGateways: resources.httpGateways, + }); + for (const r of gwResult.results) { + if (r.status === 'deleted') { + console.log(`Deleted HTTP gateway "${r.gatewayName}"`); + } else if (r.error) { + console.warn(`Warning: Failed to delete HTTP gateway "${r.gatewayName}": ${r.error}`); + } + } + } + + for (const [bundleName, bundleState] of Object.entries(resources.configBundles ?? {})) { + try { + await deleteConfigurationBundle({ region, bundleId: bundleState.bundleId }); + console.log(`Deleted config bundle "${bundleName}"`); + } catch (err) { + console.warn( + `Warning: Error during config bundle "${bundleName}" cleanup: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + } + } + } catch (err) { + // Only suppress "file not found" — other errors (corrupt state, permissions) should warn + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('ENOENT') && !msg.includes('not found') && !msg.includes('does not exist')) { + console.warn(`Warning: Could not read deployed state for resource cleanup: ${msg}`); + } + } + if (deployedTarget) { await destroyTarget({ target: deployedTarget, cdkProjectDir }); } diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index b6967ac6e..844ab437c 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -21,6 +21,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project); @@ -48,6 +51,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project); @@ -75,6 +81,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -108,6 +117,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NonExistentAgent')).toThrow( @@ -136,6 +148,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NodeAgent')).toThrow('Dev mode only supports Python'); @@ -162,6 +177,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -191,6 +209,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; // No configRoot provided @@ -220,6 +241,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -249,6 +273,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -277,6 +304,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -305,6 +335,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -333,6 +366,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -361,6 +397,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -390,6 +429,9 @@ describe('getDevConfig', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -432,6 +474,9 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); @@ -450,6 +495,9 @@ describe('getAgentPort', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); @@ -473,6 +521,9 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -499,6 +550,9 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -533,6 +587,9 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const supported = getDevSupportedAgents(project); @@ -561,6 +618,9 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const supported = getDevSupportedAgents(project); @@ -597,6 +657,9 @@ describe('getDevSupportedAgents', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const supported = getDevSupportedAgents(project); 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/handlers/invocations.ts b/src/cli/operations/dev/web-ui/handlers/invocations.ts index 4123a696d..3a6b70ed9 100644 --- a/src/cli/operations/dev/web-ui/handlers/invocations.ts +++ b/src/cli/operations/dev/web-ui/handlers/invocations.ts @@ -68,6 +68,7 @@ export async function handleInvocations( return new Promise((resolve, reject) => { const headers: Record = { 'Content-Type': 'application/json', + Accept: 'text/event-stream, */*', 'x-amzn-bedrock-agentcore-runtime-session-id': sessionId ?? randomUUID(), }; if (userId) { 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/eval/batch-eval-storage.ts b/src/cli/operations/eval/batch-eval-storage.ts new file mode 100644 index 000000000..9b55e5240 --- /dev/null +++ b/src/cli/operations/eval/batch-eval-storage.ts @@ -0,0 +1,75 @@ +import { findConfigRoot } from '../../../lib'; +import type { EvaluationResults } from '../../aws/agentcore-batch-evaluation'; +import type { BatchEvaluationResult, RunBatchEvaluationCommandResult } from './run-batch-evaluation'; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +export const BATCH_EVAL_RESULTS_DIR = 'batch-eval-results'; + +export interface BatchEvalRunRecord { + name: string; + batchEvaluationId: string; + status: string; + startedAt?: string; + completedAt?: string; + evaluators: string[]; + results: BatchEvaluationResult[]; + evaluationResults?: EvaluationResults; +} + +function getResultsDir(): string { + const configRoot = findConfigRoot(); + if (!configRoot) { + throw new Error('No agentcore project found. Run `agentcore create` first.'); + } + return join(configRoot, '.cli', BATCH_EVAL_RESULTS_DIR); +} + +export function saveBatchEvalRun(result: RunBatchEvaluationCommandResult): string { + const dir = getResultsDir(); + mkdirSync(dir, { recursive: true }); + + const id = result.batchEvaluationId ?? 'unknown'; + const filePath = join(dir, `${id}.json`); + + const record: BatchEvalRunRecord = { + name: result.name ?? 'unknown', + batchEvaluationId: id, + status: result.status ?? 'unknown', + startedAt: result.startedAt, + completedAt: result.completedAt, + evaluators: result.results.map(r => r.evaluatorId), + results: result.results, + evaluationResults: result.evaluationResults, + }; + + writeFileSync(filePath, JSON.stringify(record, null, 2)); + return filePath; +} + +export function loadBatchEvalRun(batchEvaluationId: string): BatchEvalRunRecord { + const dir = getResultsDir(); + const jsonName = batchEvaluationId.endsWith('.json') ? batchEvaluationId : `${batchEvaluationId}.json`; + const filePath = join(dir, jsonName); + + if (!existsSync(filePath)) { + throw new Error(`Batch evaluation run "${batchEvaluationId}" not found at ${filePath}`); + } + + return JSON.parse(readFileSync(filePath, 'utf-8')) as BatchEvalRunRecord; +} + +export function listBatchEvalRuns(): BatchEvalRunRecord[] { + const dir = getResultsDir(); + + if (!existsSync(dir)) { + return []; + } + + const files = readdirSync(dir) + .filter(f => f.endsWith('.json')) + .sort() + .reverse(); + + return files.map(f => JSON.parse(readFileSync(join(dir, f), 'utf-8')) as BatchEvalRunRecord); +} diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts new file mode 100644 index 000000000..0962f4e0a --- /dev/null +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -0,0 +1,347 @@ +/** + * Orchestrates running a BatchEvaluation: + * 1. Resolve agent from deployed state (for serviceNames / logGroupNames) + * 2. Build evaluators + dataSourceConfig + * 3. Call StartBatchEvaluation + * 4. Poll GetBatchEvaluation until terminal status + * 5. Return results + */ +import { ConfigIO } from '../../../lib'; +import type { DeployedState } from '../../../schema'; +import { generateClientToken, getBatchEvaluation, startBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; +import type { + CloudWatchFilterConfig, + EvaluationResults, + GetBatchEvaluationResult, + SessionMetadataEntry, +} from '../../aws/agentcore-batch-evaluation'; +import { detectRegion } from '../../aws/region'; +import { ExecLogger } from '../../logging/exec-logger'; +import { CloudWatchLogsClient, GetLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface RunBatchEvaluationOptions { + /** Agent name (from project config) */ + agent: string; + /** Evaluator IDs (Builtin.* or custom) */ + evaluators: string[]; + /** Optional name for the batch evaluation */ + name?: string; + /** Region override */ + region?: string; + /** Specific session IDs to evaluate (optional — filters CloudWatch source) */ + sessionIds?: string[]; + /** Lookback window in days (optional — filters CloudWatch source by time range) */ + lookbackDays?: number; + /** Session metadata with ground truth (assertions, expected trajectory, turns) */ + sessionMetadata?: SessionMetadataEntry[]; + /** Poll interval in ms */ + pollIntervalMs?: number; + /** Progress callback */ + onProgress?: (status: string, message: string) => void; + /** Called once the batch evaluation has been created, with ID and region for cancellation */ + onStarted?: (info: { batchEvaluationId: string; region: string }) => void; +} + +export interface BatchEvaluationResult { + evaluatorId: string; + score?: number; + label?: string; + explanation?: string; + error?: string; +} + +export interface RunBatchEvaluationCommandResult { + success: boolean; + error?: string; + batchEvaluationId?: string; + name?: string; + status?: string; + results: BatchEvaluationResult[]; + evaluationResults?: EvaluationResults; + startedAt?: string; + completedAt?: string; + logFilePath?: string; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_POLL_INTERVAL_MS = 10_000; +const TERMINAL_STATUSES = new Set(['COMPLETED', 'COMPLETED_WITH_ERRORS', 'FAILED', 'STOPPED', 'CANCELLED']); + +// ============================================================================ +// Implementation +// ============================================================================ + +export async function runBatchEvaluationCommand( + options: RunBatchEvaluationOptions +): Promise { + const { agent, evaluators, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, onProgress } = options; + + let logger: ExecLogger | undefined; + try { + logger = new ExecLogger({ command: 'batch-evaluate' }); + } catch { + // Non-fatal + } + + try { + // 1. Read project config and deployed state + logger?.startStep('Load project config'); + const configIO = new ConfigIO(); + const [projectSpec, deployedState, awsTargets] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readDeployedState(), + configIO.resolveAWSDeploymentTargets(), + ]); + + // Use the deployed target region (from aws-targets) rather than generic detectRegion() + const targetRegion = awsTargets.length > 0 ? awsTargets[0]!.region : undefined; + const { region: detectedRegion } = await detectRegion(); + const region = options.region ?? targetRegion ?? detectedRegion; + const stage = process.env.AGENTCORE_STAGE?.toLowerCase() ?? 'prod'; + logger?.log(`Region: ${region}, Stage: ${stage}`); + logger?.endStep('success'); + + // 2. Resolve agent from deployed state + logger?.startStep('Resolve agent'); + const agentState = resolveAgentState(deployedState, agent); + if (!agentState) { + const error = `Agent "${agent}" not deployed. Run \`agentcore deploy\` first.`; + logger?.log(error, 'error'); + logger?.endStep('error', error); + logger?.finalize(false); + return { success: false, error, results: [], logFilePath: logger?.logFilePath }; + } + + const runtimeId = agentState.runtimeId; + // Service name in CW logs uses project_agent format without the CDK hash suffix + const serviceName = `${projectSpec.name}_${agent}.DEFAULT`; + const runtimeLogGroup = `/aws/bedrock-agentcore/runtimes/${runtimeId}-DEFAULT`; + + logger?.log(`Agent: ${agent} (runtime: ${runtimeId})`); + logger?.log(`Service name: ${serviceName}`); + logger?.log(`Log group: ${runtimeLogGroup}`); + logger?.endStep('success'); + + // 2b. Resolve evaluator names to deployed IDs + // Handles: "Builtin.Correctness", "arn:aws:...:evaluator/Builtin.Correctness", or custom evaluator names + const targetResources = Object.values(deployedState.targets).find(t => t.resources?.runtimes?.[agent])?.resources; + const resolvedEvaluators = evaluators.map(name => { + // Extract short name from ARN if passed (e.g. "arn:aws:bedrock-agentcore:::evaluator/Builtin.Correctness" → "Builtin.Correctness") + const shortName = name.includes('evaluator/') ? name.split('evaluator/').pop()! : name; + if (shortName.startsWith('Builtin.')) return shortName; + const deployed = targetResources?.evaluators?.[shortName]; + if (deployed?.evaluatorId) { + logger?.log(`Resolved evaluator "${shortName}" → ${deployed.evaluatorId}`); + return deployed.evaluatorId; + } + logger?.log(`Evaluator "${shortName}" not found in deployed state, passing as-is`, 'warn'); + return shortName; + }); + + // 3. Start the batch evaluation + logger?.startStep('Start batch evaluation'); + let evalName: string; + if (options.name) { + if (!/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/.test(options.name)) { + return { + success: false, + error: `Batch evaluation name must start with a letter and contain only letters, digits, and underscores (max 48 chars). Got: "${options.name}"`, + results: [], + logFilePath: logger?.logFilePath, + }; + } + evalName = options.name; + } else { + evalName = `${projectSpec.name}_${agent}_${Date.now()}`.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 48); + } + + onProgress?.('starting', `Starting batch evaluation "${evalName}"...`); + + // Build optional filter config for CloudWatch filtering + // API requires either sessionIds OR timeRange, not both — sessionIds takes precedence + // Merge explicit sessionIds with any sessionIds from sessionMetadata (deduplicated) + const metadataSessionIds = options.sessionMetadata?.map(m => m.sessionId).filter(Boolean) ?? []; + const explicitSessionIds = options.sessionIds ?? []; + const effectiveSessionIds = [...new Set([...explicitSessionIds, ...metadataSessionIds])]; + const hasSessionIds = effectiveSessionIds.length > 0; + + const filterConfig: CloudWatchFilterConfig | undefined = (() => { + if (hasSessionIds) { + return { sessionIds: effectiveSessionIds }; + } + if (options.lookbackDays) { + const endTime = new Date().toISOString(); + const startTime = new Date(Date.now() - options.lookbackDays * 24 * 60 * 60 * 1000).toISOString(); + return { timeRange: { startTime, endTime } }; + } + return undefined; + })(); + + const startPayload = { + region, + name: evalName, + evaluators: resolvedEvaluators.map(id => ({ evaluatorId: id })), + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: [serviceName], + logGroupNames: [runtimeLogGroup], + ...(filterConfig ? { filterConfig } : {}), + }, + }, + ...(options.sessionMetadata && options.sessionMetadata.length > 0 + ? { evaluationMetadata: { sessionMetadata: options.sessionMetadata } } + : {}), + clientToken: generateClientToken(), + }; + + logger?.log(`Request payload:\n${JSON.stringify(startPayload, null, 2)}`); + + const startResult = await startBatchEvaluation(startPayload); + + logger?.log(`Response: ${JSON.stringify(startResult, null, 2)}`); + logger?.endStep('success'); + + onProgress?.('running', `Batch evaluation started (ID: ${startResult.batchEvaluationId})`); + onProgress?.('running', 'This may take a few minutes...'); + options.onStarted?.({ batchEvaluationId: startResult.batchEvaluationId, region }); + + // 4. Poll for completion + logger?.startStep('Poll for completion'); + let current: GetBatchEvaluationResult = { + batchEvaluationId: startResult.batchEvaluationId, + batchEvaluationArn: startResult.batchEvaluationArn, + name: startResult.name, + status: startResult.status, + }; + + while (!TERMINAL_STATUSES.has(current.status)) { + await sleep(pollIntervalMs); + + current = await getBatchEvaluation({ + region, + batchEvaluationId: startResult.batchEvaluationId, + }); + + onProgress?.('polling', `Status: ${current.status}`); + logger?.log(`Poll status: ${current.status}`); + } + + if (current.status !== 'COMPLETED' && current.status !== 'COMPLETED_WITH_ERRORS') { + const reasons = current.errorDetails?.join('; ') ?? ''; + const error = `Batch evaluation finished with status: ${current.status}${reasons ? ` — ${reasons}` : ''}`; + logger?.log(error, 'error'); + logger?.log(`Full poll response:\n${JSON.stringify(current, null, 2)}`, 'error'); + logger?.endStep('error', error); + logger?.finalize(false); + return { + success: false, + error, + batchEvaluationId: startResult.batchEvaluationId, + name: evalName, + status: current.status, + results: [], + logFilePath: logger?.logFilePath, + }; + } + + logger?.endStep('success'); + + // 5. Fetch results from CloudWatch output logs + logger?.startStep('Fetch results'); + let results: BatchEvaluationResult[] = []; + + const cwDest = current.outputConfig?.cloudWatchConfig; + if (cwDest) { + try { + results = await fetchResultsFromCloudWatch(region, cwDest.logGroupName, cwDest.logStreamName); + logger?.log(`Fetched ${results.length} result(s) from CloudWatch`); + } catch (cwErr: unknown) { + logger?.log(`Failed to fetch CW results: ${cwErr instanceof Error ? cwErr.message : String(cwErr)}`, 'error'); + } + } + + logger?.endStep('success'); + + logger?.log(`Results: ${JSON.stringify(results, null, 2)}`); + logger?.finalize(true); + + return { + success: true, + batchEvaluationId: startResult.batchEvaluationId, + name: evalName, + status: current.status, + results, + evaluationResults: current.evaluationResults, + startedAt: current.createdAt, + completedAt: current.updatedAt ?? new Date().toISOString(), + logFilePath: logger?.logFilePath, + }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + logger?.log(error, 'error'); + logger?.finalize(false); + return { success: false, error, results: [], logFilePath: logger?.logFilePath }; + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function resolveAgentState( + deployedState: DeployedState, + agentName: string +): { runtimeId: string; runtimeArn: string; roleArn?: string } | undefined { + for (const target of Object.values(deployedState.targets)) { + const agent = target.resources?.runtimes?.[agentName]; + if (agent) return agent; + } + return undefined; +} + +async function fetchResultsFromCloudWatch( + region: string, + logGroupName: string, + logStreamName: string +): Promise { + const client = new CloudWatchLogsClient({ region }); + const response = await client.send( + new GetLogEventsCommand({ + logGroupName, + logStreamName, + startFromHead: true, + }) + ); + + const results: BatchEvaluationResult[] = []; + for (const event of response.events ?? []) { + if (!event.message) continue; + try { + const parsed = JSON.parse(event.message) as Record; + const attrs = (parsed.attributes ?? {}) as Record; + const evaluatorId = attrs['gen_ai.evaluation.name'] as string | undefined; + if (!evaluatorId) continue; + + results.push({ + evaluatorId, + score: attrs['gen_ai.evaluation.score.value'] as number | undefined, + label: attrs['gen_ai.evaluation.score.label'] as string | undefined, + explanation: attrs['gen_ai.evaluation.explanation'] as string | undefined, + }); + } catch { + // Skip non-JSON or malformed entries + } + } + return results; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/cli/operations/eval/run-eval.ts b/src/cli/operations/eval/run-eval.ts index d130438ff..90cd519c7 100644 --- a/src/cli/operations/eval/run-eval.ts +++ b/src/cli/operations/eval/run-eval.ts @@ -1,12 +1,12 @@ import { getCredentialProvider } from '../../aws'; import { evaluate } from '../../aws/agentcore'; +import type { EvaluationReferenceInput } from '../../aws/agentcore'; import { getEvaluator } from '../../aws/agentcore-control'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; import type { DeployedProjectConfig } from '../resolve-agent'; import { loadDeployedProjectConfig, resolveAgent } from '../resolve-agent'; import { generateFilename, saveEvalRun } from './storage'; import type { EvalEvaluatorResult, EvalRunResult, EvalSessionScore, RunEvalOptions, SessionInfo } from './types'; -import type { EvaluationReferenceInput } from '@aws-sdk/client-bedrock-agentcore'; import { CloudWatchLogsClient, GetQueryResultsCommand, StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'; import type { ResultField } from '@aws-sdk/client-cloudwatch-logs'; import type { DocumentType } from '@smithy/types'; diff --git a/src/cli/operations/fetch-access/list-gateways.ts b/src/cli/operations/fetch-access/list-gateways.ts index 03102ff26..0e70559ce 100644 --- a/src/cli/operations/fetch-access/list-gateways.ts +++ b/src/cli/operations/fetch-access/list-gateways.ts @@ -30,5 +30,17 @@ export async function listGateways( }); } + // Include HTTP gateways (auto-created for A/B testing) + const deployedHttpGateways = target.resources?.httpGateways ?? {}; + for (const httpGateway of projectSpec.httpGateways ?? []) { + const deployed = deployedHttpGateways[httpGateway.name]; + if (!deployed?.gatewayArn) continue; + + gateways.push({ + name: httpGateway.name, + authType: 'AWS_IAM', + }); + } + return gateways; } diff --git a/src/cli/operations/identity/__tests__/credential-ops.test.ts b/src/cli/operations/identity/__tests__/credential-ops.test.ts index 3913f54ac..32568765b 100644 --- a/src/cli/operations/identity/__tests__/credential-ops.test.ts +++ b/src/cli/operations/identity/__tests__/credential-ops.test.ts @@ -147,20 +147,3 @@ describe('resolveCredentialStrategy', () => { expect(result.isAgentScoped).toBe(true); }); }); - -// TODO: OAuth credential creation needs to be added to CredentialPrimitive. -// These tests were ported from main's create-identity.ts OAuth support. -// Once CredentialPrimitive.addOAuth() is implemented, convert these to use primitive.addOAuth(). -describe('createCredential OAuth', () => { - afterEach(() => vi.clearAllMocks()); - - it.todo('creates OAuth credential and writes to project'); - - it.todo('writes CLIENT_ID and CLIENT_SECRET to env'); - - it.todo('uppercases name in env var keys'); - - it.todo('throws when OAuth credential already exists'); - - it.todo('includes scopes when provided'); -}); diff --git a/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts b/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts new file mode 100644 index 000000000..5e0fb668a --- /dev/null +++ b/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts @@ -0,0 +1,199 @@ +import type { ConfigIO } from '../../../../lib'; +import type { RecommendationResult } from '../../../aws/agentcore-recommendation'; +import { applyRecommendationToBundle } from '../apply-to-bundle'; +import { describe, expect, it, vi } from 'vitest'; + +const { RUNTIME_ARN, BUNDLE_ARN, NEW_VERSION_ID } = vi.hoisted(() => ({ + RUNTIME_ARN: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/myAgent-abc123', + BUNDLE_ARN: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:configuration-bundle/MyBundle-xyz789', + NEW_VERSION_ID: 'v2-recommendation', +})); + +vi.mock('../../../aws/agentcore-config-bundles', () => ({ + getConfigurationBundleVersion: vi.fn().mockResolvedValue({ + bundleArn: BUNDLE_ARN, + bundleId: 'MyBundle-xyz789', + bundleName: 'MyBundle', + versionId: NEW_VERSION_ID, + components: { + [RUNTIME_ARN]: { + configuration: { + systemPrompt: 'new improved prompt', + temperature: 0.8, + }, + }, + }, + lineageMetadata: { + commitMessage: 'Recommendation applied', + }, + createdAt: '2026-04-12T00:00:00Z', + versionCreatedAt: '2026-04-12T00:00:00Z', + }), +})); + +function makeConfigIO(spec: Record, deployedState?: Record) { + const writeSpecSpy = vi.fn().mockResolvedValue(undefined); + const writeDeployedStateSpy = vi.fn().mockResolvedValue(undefined); + const configIO = { + readProjectSpec: vi.fn().mockResolvedValue(spec), + writeProjectSpec: writeSpecSpy, + readDeployedState: vi.fn().mockResolvedValue( + deployedState ?? { + targets: { + default: { + resources: { + configBundles: { + MyBundle: { + bundleId: 'MyBundle-xyz789', + bundleArn: BUNDLE_ARN, + versionId: 'v1', + }, + }, + }, + }, + }, + } + ), + writeDeployedState: writeDeployedStateSpy, + } as unknown as ConfigIO; + return { configIO, writeSpecSpy, writeDeployedStateSpy }; +} + +function makeSpec(systemPrompt = 'old prompt') { + return { + name: 'testProject', + configBundles: [ + { + name: 'MyBundle', + type: 'ConfigurationBundle', + components: { + [RUNTIME_ARN]: { + configuration: { + systemPrompt, + temperature: 0.7, + }, + }, + }, + branchName: 'main', + commitMessage: 'Initial', + }, + ], + }; +} + +describe('applyRecommendationToBundle', () => { + it('syncs local config from server-created version by bundle name', async () => { + const spec = makeSpec(); + const { configIO, writeSpecSpy, writeDeployedStateSpy } = makeConfigIO(spec); + + const result: RecommendationResult = { + systemPromptRecommendationResult: { + recommendedSystemPrompt: 'new improved prompt', + configurationBundle: { bundleArn: BUNDLE_ARN, versionId: NEW_VERSION_ID }, + }, + }; + + const applyResult = await applyRecommendationToBundle( + { bundleName: 'MyBundle', result, region: 'us-east-1' }, + configIO + ); + + expect(applyResult.success).toBe(true); + expect(applyResult.newVersionId).toBe(NEW_VERSION_ID); + + // Verify spec was written with server components + expect(writeSpecSpy).toHaveBeenCalledTimes(1); + const writtenSpec = writeSpecSpy.mock.calls[0]![0]; + expect(writtenSpec.configBundles[0].components[RUNTIME_ARN].configuration.systemPrompt).toBe('new improved prompt'); + // Server version has temperature 0.8 (not local 0.7) + expect(writtenSpec.configBundles[0].components[RUNTIME_ARN].configuration.temperature).toBe(0.8); + // Commit message from lineage metadata + expect(writtenSpec.configBundles[0].commitMessage).toBe('Recommendation applied'); + + // Verify deployed state was updated with new version + expect(writeDeployedStateSpy).toHaveBeenCalledTimes(1); + const writtenState = writeDeployedStateSpy.mock.calls[0]![0]; + expect(writtenState.targets.default.resources.configBundles.MyBundle.versionId).toBe(NEW_VERSION_ID); + }); + + it('syncs local config by bundle ARN via deployed state', async () => { + const spec = makeSpec(); + const { configIO } = makeConfigIO(spec); + + const result: RecommendationResult = { + systemPromptRecommendationResult: { + recommendedSystemPrompt: 'ARN-resolved prompt', + configurationBundle: { bundleArn: BUNDLE_ARN, versionId: NEW_VERSION_ID }, + }, + }; + + const applyResult = await applyRecommendationToBundle( + { bundleArn: BUNDLE_ARN, result, region: 'us-east-1' }, + configIO + ); + + expect(applyResult.success).toBe(true); + expect(applyResult.newVersionId).toBe(NEW_VERSION_ID); + }); + + it('syncs tool description recommendation result', async () => { + const spec = makeSpec(); + const { configIO } = makeConfigIO(spec); + + const result: RecommendationResult = { + toolDescriptionRecommendationResult: { + tools: [{ toolName: 'search', recommendedToolDescription: 'new desc' }], + configurationBundle: { bundleArn: BUNDLE_ARN, versionId: NEW_VERSION_ID }, + }, + }; + + const applyResult = await applyRecommendationToBundle( + { bundleName: 'MyBundle', result, region: 'us-east-1' }, + configIO + ); + + expect(applyResult.success).toBe(true); + expect(applyResult.newVersionId).toBe(NEW_VERSION_ID); + }); + + it('returns error when result has no configurationBundle', async () => { + const spec = makeSpec(); + const { configIO, writeSpecSpy } = makeConfigIO(spec); + + const result: RecommendationResult = { + systemPromptRecommendationResult: { + recommendedSystemPrompt: 'new prompt', + }, + }; + + const applyResult = await applyRecommendationToBundle( + { bundleName: 'MyBundle', result, region: 'us-east-1' }, + configIO + ); + + expect(applyResult.success).toBe(false); + expect(applyResult.error).toContain('does not contain a new config bundle version'); + expect(writeSpecSpy).not.toHaveBeenCalled(); + }); + + it('returns error when bundle not found in agentcore.json', async () => { + const spec = makeSpec(); + const { configIO, writeSpecSpy } = makeConfigIO(spec); + + const result: RecommendationResult = { + systemPromptRecommendationResult: { + recommendedSystemPrompt: 'new', + configurationBundle: { bundleArn: BUNDLE_ARN, versionId: NEW_VERSION_ID }, + }, + }; + + const applyResult = await applyRecommendationToBundle( + { bundleName: 'NonExistent', result, region: 'us-east-1' }, + configIO + ); + + expect(applyResult.success).toBe(false); + expect(applyResult.error).toContain('NonExistent'); + expect(writeSpecSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/operations/recommendation/__tests__/fetch-session-spans.test.ts b/src/cli/operations/recommendation/__tests__/fetch-session-spans.test.ts new file mode 100644 index 000000000..4395edd23 --- /dev/null +++ b/src/cli/operations/recommendation/__tests__/fetch-session-spans.test.ts @@ -0,0 +1,224 @@ +import { fetchSessionSpans } from '../fetch-session-spans'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSearchLogs = vi.fn(); + +vi.mock('../../../aws/cloudwatch', () => ({ + searchLogs: (...args: unknown[]) => mockSearchLogs(...args), +})); + +/** + * Helper: create an async generator from an array of log events. + */ +async function* fakeLogStream(events: { timestamp: number; message: string }[]) { + for (const e of events) { + yield await Promise.resolve(e); + } +} + +/** Helper: create an async generator that throws on first iteration. */ +// eslint-disable-next-line require-yield +async function* fakeErrorStream(error: Error): AsyncGenerator<{ timestamp: number; message: string }> { + await Promise.resolve(); + throw error; +} + +const SESSION_ID = 'sess-abc-123'; + +function makeSpanRecord(traceId: string, spanId: string) { + return { + timestamp: Date.now(), + message: JSON.stringify({ + traceId, + spanId, + scope: { name: 'strands.telemetry.tracer' }, + attributes: { 'session.id': SESSION_ID }, + body: {}, + }), + }; +} + +function makeLogRecord(traceId: string, spanId: string, sessionId: string) { + return { + timestamp: Date.now(), + message: JSON.stringify({ + traceId, + spanId, + attributes: { 'session.id': sessionId }, + body: { + input: { messages: [{ content: { content: 'hello' }, role: 'user' }] }, + output: { messages: [{ content: { content: 'hi' }, role: 'assistant' }] }, + }, + }), + }; +} + +describe('fetchSessionSpans', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('combines span records and log records for the same session', async () => { + const spanEvents = [makeSpanRecord('trace1', 'span1'), makeSpanRecord('trace1', 'span2')]; + const logEvents = [makeLogRecord('trace1', 'span3', SESSION_ID)]; + + // First call = aws/spans, second call = runtime log group + mockSearchLogs.mockReturnValueOnce(fakeLogStream(spanEvents)).mockReturnValueOnce(fakeLogStream(logEvents)); + + const result = await fetchSessionSpans({ + region: 'us-east-1', + runtimeId: 'myproject_MyAgent-QMd093Gl4O', + sessionId: SESSION_ID, + }); + + expect(result.spans).toHaveLength(3); + expect(result.spanRecordCount).toBe(2); + expect(result.logRecordCount).toBe(1); + }); + + it('filters out log records from other sessions', async () => { + const spanEvents = [makeSpanRecord('trace1', 'span1')]; + const logEvents = [ + makeLogRecord('trace1', 'span2', SESSION_ID), + makeLogRecord('trace1', 'span3', 'other-session-id'), + ]; + + mockSearchLogs.mockReturnValueOnce(fakeLogStream(spanEvents)).mockReturnValueOnce(fakeLogStream(logEvents)); + + const result = await fetchSessionSpans({ + region: 'us-east-1', + runtimeId: 'myproject_MyAgent-QMd093Gl4O', + sessionId: SESSION_ID, + }); + + expect(result.spans).toHaveLength(2); + expect(result.logRecordCount).toBe(1); + }); + + it('returns empty spans when no records found', async () => { + mockSearchLogs.mockReturnValueOnce(fakeLogStream([])).mockReturnValueOnce(fakeLogStream([])); + + const result = await fetchSessionSpans({ + region: 'us-east-1', + runtimeId: 'myproject_MyAgent-QMd093Gl4O', + sessionId: SESSION_ID, + }); + + expect(result.spans).toHaveLength(0); + expect(result.spanRecordCount).toBe(0); + expect(result.logRecordCount).toBe(0); + }); + + it('handles ResourceNotFoundException gracefully (log group does not exist)', async () => { + // Spans log group works, runtime log group does not exist + mockSearchLogs + .mockReturnValueOnce(fakeLogStream([makeSpanRecord('t1', 's1')])) + .mockReturnValueOnce( + fakeErrorStream(new Error('ResourceNotFoundException: The specified log group does not exist')) + ); + + const result = await fetchSessionSpans({ + region: 'us-east-1', + runtimeId: 'myproject_MyAgent-QMd093Gl4O', + sessionId: SESSION_ID, + }); + + // Should still return span records from aws/spans + expect(result.spans).toHaveLength(1); + expect(result.spanRecordCount).toBe(1); + expect(result.logRecordCount).toBe(0); + }); + + it('rethrows non-ResourceNotFoundException errors', async () => { + mockSearchLogs + .mockReturnValueOnce(fakeLogStream([])) + .mockReturnValueOnce(fakeErrorStream(new Error('AccessDeniedException: Not authorized'))); + + await expect( + fetchSessionSpans({ + region: 'us-east-1', + runtimeId: 'myproject_MyAgent-QMd093Gl4O', + sessionId: SESSION_ID, + }) + ).rejects.toThrow('AccessDeniedException'); + }); + + it('skips unparseable log messages', async () => { + const spanEvents = [{ timestamp: Date.now(), message: 'not-valid-json' }, makeSpanRecord('trace1', 'span1')]; + + mockSearchLogs.mockReturnValueOnce(fakeLogStream(spanEvents)).mockReturnValueOnce(fakeLogStream([])); + + const result = await fetchSessionSpans({ + region: 'us-east-1', + runtimeId: 'myproject_MyAgent-QMd093Gl4O', + sessionId: SESSION_ID, + }); + + expect(result.spans).toHaveLength(1); + }); + + it('uses correct log group names', async () => { + mockSearchLogs.mockReturnValueOnce(fakeLogStream([])).mockReturnValueOnce(fakeLogStream([])); + + await fetchSessionSpans({ + region: 'us-east-1', + runtimeId: 'myproject_MyAgent-QMd093Gl4O', + sessionId: SESSION_ID, + lookbackDays: 3, + }); + + expect(mockSearchLogs).toHaveBeenCalledTimes(2); + + // First call: aws/spans + const spanCall = mockSearchLogs.mock.calls[0]![0]; + expect(spanCall.logGroupName).toBe('aws/spans'); + expect(spanCall.filterPattern).toContain(SESSION_ID); + + // Second call: runtime log group + const logCall = mockSearchLogs.mock.calls[1]![0]; + expect(logCall.logGroupName).toBe('/aws/bedrock-agentcore/runtimes/myproject_MyAgent-QMd093Gl4O-DEFAULT'); + expect(logCall.filterPattern).toContain('"body" "input"'); + }); + + it('calls onProgress callback', async () => { + mockSearchLogs + .mockReturnValueOnce(fakeLogStream([makeSpanRecord('t1', 's1')])) + .mockReturnValueOnce(fakeLogStream([])); + + const progress: string[] = []; + await fetchSessionSpans({ + region: 'us-east-1', + runtimeId: 'rt-123', + sessionId: SESSION_ID, + onProgress: msg => progress.push(msg), + }); + + expect(progress.length).toBeGreaterThan(0); + expect(progress.some(m => m.includes('span records'))).toBe(true); + }); + + it('matches log records by session ID in body (fallback)', async () => { + // Log record with session ID only in body, not in attributes + const logEvent = { + timestamp: Date.now(), + message: JSON.stringify({ + traceId: 'trace1', + spanId: 'span1', + attributes: {}, + body: { + input: { messages: [{ content: { content: `session ${SESSION_ID} data` }, role: 'user' }] }, + }, + }), + }; + + mockSearchLogs.mockReturnValueOnce(fakeLogStream([])).mockReturnValueOnce(fakeLogStream([logEvent])); + + const result = await fetchSessionSpans({ + region: 'us-east-1', + runtimeId: 'rt-123', + sessionId: SESSION_ID, + }); + + expect(result.logRecordCount).toBe(1); + }); +}); diff --git a/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts b/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts new file mode 100644 index 000000000..f6a60b6e8 --- /dev/null +++ b/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts @@ -0,0 +1,134 @@ +import { listAllRecommendations, loadRecommendationRun, saveRecommendationRun } from '../recommendation-storage'; +import type { RunRecommendationCommandResult } from '../types'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFindConfigRoot = vi.fn(); + +vi.mock('../../../../lib', () => ({ + findConfigRoot: () => mockFindConfigRoot(), +})); + +function makeTmpDir(): string { + const dir = join(tmpdir(), `recommendation-storage-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function makeResult(overrides: Partial = {}): RunRecommendationCommandResult { + return { + success: true, + recommendationId: 'rec-123', + status: 'COMPLETED', + startedAt: '2026-03-24T10:00:00.000Z', + completedAt: '2026-03-24T10:05:00.000Z', + result: { + systemPromptRecommendationResult: { + recommendedSystemPrompt: 'You are an expert booking assistant.', + }, + }, + ...overrides, + }; +} + +describe('recommendation-storage', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + mockFindConfigRoot.mockReturnValue(tmpDir); + }); + + afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + describe('saveRecommendationRun', () => { + it('creates directory and writes JSON file', () => { + const result = makeResult(); + const filePath = saveRecommendationRun('rec-123', result, 'SYSTEM_PROMPT_RECOMMENDATION', 'booking-agent', [ + 'Builtin.Helpfulness', + ]); + + expect(filePath).toContain('recommendations'); + expect(filePath).toContain('rec-123.json'); + expect(existsSync(filePath)).toBe(true); + }); + + it('writes valid JSON that can be read back', () => { + const result = makeResult(); + saveRecommendationRun('rec-123', result, 'SYSTEM_PROMPT_RECOMMENDATION', 'booking-agent', [ + 'Builtin.Helpfulness', + ]); + + const loaded = loadRecommendationRun('rec-123'); + expect(loaded.recommendationId).toBe('rec-123'); + expect(loaded.type).toBe('SYSTEM_PROMPT_RECOMMENDATION'); + expect(loaded.agent).toBe('booking-agent'); + expect(loaded.evaluators).toEqual(['Builtin.Helpfulness']); + expect(loaded.result?.systemPromptRecommendationResult?.recommendedSystemPrompt).toBe( + 'You are an expert booking assistant.' + ); + }); + }); + + describe('loadRecommendationRun', () => { + it('loads a previously saved recommendation', () => { + saveRecommendationRun('rec-123', makeResult(), 'SYSTEM_PROMPT_RECOMMENDATION', 'agent', ['eval']); + const loaded = loadRecommendationRun('rec-123'); + expect(loaded.status).toBe('COMPLETED'); + }); + + it('accepts filename with .json extension', () => { + saveRecommendationRun('rec-123', makeResult(), 'SYSTEM_PROMPT_RECOMMENDATION', 'agent', ['eval']); + const loaded = loadRecommendationRun('rec-123.json'); + expect(loaded.recommendationId).toBe('rec-123'); + }); + + it('throws for a non-existent recommendation', () => { + expect(() => loadRecommendationRun('nonexistent')).toThrow('not found'); + }); + }); + + describe('listAllRecommendations', () => { + it('returns empty array when no recommendations exist', () => { + expect(listAllRecommendations()).toEqual([]); + }); + + it('returns saved recommendations in reverse order', () => { + saveRecommendationRun( + 'rec-aaa', + makeResult({ recommendationId: 'rec-aaa' }), + 'SYSTEM_PROMPT_RECOMMENDATION', + 'agent', + ['eval'] + ); + saveRecommendationRun( + 'rec-zzz', + makeResult({ recommendationId: 'rec-zzz' }), + 'TOOL_DESCRIPTION_RECOMMENDATION', + 'agent', + ['eval'] + ); + + const all = listAllRecommendations(); + expect(all).toHaveLength(2); + expect(all[0]!.recommendationId).toBe('rec-zzz'); + expect(all[1]!.recommendationId).toBe('rec-aaa'); + }); + }); + + describe('error when no config root', () => { + it('throws when findConfigRoot returns null', () => { + mockFindConfigRoot.mockReturnValue(null); + expect(() => + saveRecommendationRun('rec-123', makeResult(), 'SYSTEM_PROMPT_RECOMMENDATION', 'agent', ['eval']) + ).toThrow('No agentcore project found'); + }); + }); +}); diff --git a/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts new file mode 100644 index 000000000..b26a59b32 --- /dev/null +++ b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts @@ -0,0 +1,700 @@ +import { runRecommendationCommand } from '../run-recommendation'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock dependencies — paths are relative to the file under test (run-recommendation.ts) +const mockReadProjectSpec = vi.fn().mockResolvedValue({ name: 'test-project' }); +const mockReadDeployedState = vi.fn().mockResolvedValue({ + targets: { + default: { + resources: { + runtimes: { + MyAgent: { + runtimeId: 'rt-abc123', + runtimeArn: 'arn:aws:bedrock:us-east-1:998846730471:agent-runtime/rt-abc123', + }, + }, + evaluators: { + MyEvaluator: { + evaluatorArn: 'arn:aws:bedrock-agentcore:us-east-1:998846730471:evaluator/my-eval-abc1234567', + }, + }, + }, + }, + }, +}); + +vi.mock('../../../../lib', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + readDeployedState = mockReadDeployedState; + resolveAWSDeploymentTargets = vi.fn().mockResolvedValue([{ region: 'us-east-1' }]); + }, +})); + +vi.mock('../../../aws/region', () => ({ + detectRegion: vi.fn().mockResolvedValue({ region: 'us-east-1' }), +})); + +const mockStartRecommendation = vi.fn(); +const mockGetRecommendation = vi.fn(); + +vi.mock('../../../aws/agentcore-recommendation', () => ({ + startRecommendation: (...args: unknown[]) => mockStartRecommendation(...args), + getRecommendation: (...args: unknown[]) => mockGetRecommendation(...args), +})); + +const mockFetchSessionSpans = vi.fn(); +vi.mock('../fetch-session-spans', () => ({ + fetchSessionSpans: (...args: unknown[]) => mockFetchSessionSpans(...args), +})); + +const mockReadFileSync = vi.fn(); +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { ...actual, readFileSync: (...args: unknown[]) => mockReadFileSync(...args) }; +}); + +describe('runRecommendationCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns error when agent is not deployed', async () => { + mockReadDeployedState.mockResolvedValueOnce({ targets: {} }); + + const result = await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'NonExistentAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'You are helpful.', + traceSource: 'cloudwatch', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('NonExistentAgent'); + expect(result.error).toContain('not deployed'); + }); + + it('returns error when evaluator cannot be resolved', async () => { + const result = await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['UnknownEvaluator'], + inputSource: 'inline', + inlineContent: 'You are helpful.', + traceSource: 'cloudwatch', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('UnknownEvaluator'); + expect(result.error).toContain('not found'); + }); + + it('returns result on COMPLETED status', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-001', + recommendationArn: 'arn:rec-001', + name: 'test-rec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }); + + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-001', + status: 'COMPLETED', + createdAt: '2026-03-30T00:00:00Z', + completedAt: '2026-03-30T00:01:00Z', + recommendationResult: { + systemPromptRecommendationResult: { + recommendedSystemPrompt: 'Optimized prompt', + explanation: 'Made clearer', + }, + }, + }); + + const result = await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'You are helpful.', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + expect(result.success).toBe(true); + expect(result.recommendationId).toBe('rec-001'); + expect(result.status).toBe('COMPLETED'); + expect(result.result?.systemPromptRecommendationResult?.recommendedSystemPrompt).toBe('Optimized prompt'); + }); + + it('returns error on FAILED status', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-002', + recommendationArn: 'arn:rec-002', + name: 'test-rec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }); + + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-002', + status: 'FAILED', + }); + + const result = await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'You are helpful.', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('FAILED'); + expect(result.recommendationId).toBe('rec-002'); + }); + + it('expands Builtin.* evaluator to full ARN in startRecommendation call', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-003', + status: 'COMPLETED', + }); + + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-003', + status: 'COMPLETED', + recommendationResult: {}, + }); + + await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const evaluators = callArgs.recommendationConfig.systemPromptRecommendationConfig.evaluationConfig.evaluators; + expect(evaluators[0].evaluatorArn).toBe('arn:aws:bedrock-agentcore:::evaluator/Builtin.Toxicity'); + }); + + it('uses account ID from runtime ARN in log group ARN', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-004', + status: 'COMPLETED', + }); + + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-004', + status: 'COMPLETED', + recommendationResult: {}, + }); + + await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const logGroupArn = + callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces.cloudwatchLogs.logGroupArns[0]; + expect(logGroupArn).toContain(':998846730471:'); + expect(logGroupArn).not.toContain(':*:'); + }); + + it('resolves custom evaluator from deployed state', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-005', + status: 'COMPLETED', + }); + + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-005', + status: 'COMPLETED', + recommendationResult: {}, + }); + + await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['MyEvaluator'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const evaluators = callArgs.recommendationConfig.systemPromptRecommendationConfig.evaluationConfig.evaluators; + expect(evaluators[0].evaluatorArn).toBe( + 'arn:aws:bedrock-agentcore:us-east-1:998846730471:evaluator/my-eval-abc1234567' + ); + }); + + it('builds TOOL_DESCRIPTION_RECOMMENDATION config with toolName:description pairs', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-006', + status: 'COMPLETED', + }); + + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-006', + status: 'COMPLETED', + recommendationResult: {}, + }); + + await runRecommendationCommand({ + type: 'TOOL_DESCRIPTION_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + tools: ['search:Search the web for info', 'calculate:Perform math calculations'], + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const tools = + callArgs.recommendationConfig.toolDescriptionRecommendationConfig.toolDescription.toolDescriptionText.tools; + expect(tools).toHaveLength(2); + expect(tools[0].toolName).toBe('search'); + expect(tools[0].toolDescription.text).toBe('Search the web for info'); + expect(tools[1].toolName).toBe('calculate'); + expect(tools[1].toolDescription.text).toBe('Perform math calculations'); + }); + + it('catches and returns errors from startRecommendation', async () => { + mockStartRecommendation.mockRejectedValue(new Error('API timeout')); + + const result = await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'cloudwatch', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('API timeout'); + }); + + it('retries transient poll failures and succeeds', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-retry-ok', + recommendationArn: 'arn:rec-retry-ok', + name: 'test-rec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }); + + // First poll fails, second succeeds + mockGetRecommendation.mockRejectedValueOnce(new Error('fetch failed')).mockResolvedValueOnce({ + recommendationId: 'rec-retry-ok', + status: 'COMPLETED', + recommendationResult: { + systemPromptRecommendationResult: { recommendedSystemPrompt: 'Better prompt' }, + }, + }); + + const result = await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + expect(result.success).toBe(true); + expect(result.recommendationId).toBe('rec-retry-ok'); + expect(mockGetRecommendation).toHaveBeenCalledTimes(2); + }); + + it('fails after max consecutive poll retries', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-retry-fail', + recommendationArn: 'arn:rec-retry-fail', + name: 'test-rec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }); + + mockGetRecommendation.mockRejectedValue(new Error('fetch failed')); + + const result = await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('consecutive errors'); + expect(result.error).toContain('fetch failed'); + expect(result.error).toContain('rec-retry-fail'); + expect(mockGetRecommendation).toHaveBeenCalledTimes(3); + }); + + it('times out after max poll duration', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-timeout', + recommendationArn: 'arn:rec-timeout', + name: 'test-rec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }); + + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-timeout', + status: 'IN_PROGRESS', + }); + + const result = await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + maxPollDurationMs: 0, // Immediately timeout + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Polling timed out'); + expect(result.error).toContain('rec-timeout'); + }); + + it('reads system prompt from file when inputSource is file', async () => { + mockReadFileSync.mockReturnValue('You are a healthcare assistant.'); + + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-file', + status: 'COMPLETED', + }); + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-file', + status: 'COMPLETED', + recommendationResult: {}, + }); + + await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Helpfulness'], + inputSource: 'file', + promptFile: '/tmp/prompt.txt', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + expect(mockReadFileSync).toHaveBeenCalledWith('/tmp/prompt.txt', 'utf-8'); + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const systemPrompt = callArgs.recommendationConfig.systemPromptRecommendationConfig.systemPrompt; + expect(systemPrompt.text).toBe('You are a healthcare assistant.'); + }); + + it('uses inline sessionSpans from spans-file trace source', async () => { + const fakeSpans = [ + { traceId: 't1', spanId: 's1', body: {} }, + { traceId: 't1', spanId: 's2', body: {} }, + ]; + mockReadFileSync.mockReturnValue(JSON.stringify(fakeSpans)); + + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-spans', + status: 'COMPLETED', + }); + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-spans', + status: 'COMPLETED', + recommendationResult: {}, + }); + + await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'spans-file', + spansFile: '/tmp/spans.json', + pollIntervalMs: 0, + }); + + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const traces = callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces; + expect(traces.sessionSpans).toHaveLength(2); + expect(traces.cloudwatchLogs).toBeUndefined(); + }); + + it('wraps single span object in array for spans-file', async () => { + const singleSpan = { traceId: 't1', spanId: 's1', body: {} }; + mockReadFileSync.mockReturnValue(JSON.stringify(singleSpan)); + + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-single', + status: 'COMPLETED', + }); + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-single', + status: 'COMPLETED', + recommendationResult: {}, + }); + + await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'spans-file', + spansFile: '/tmp/single.json', + pollIntervalMs: 0, + }); + + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const traces = callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces; + expect(traces.sessionSpans).toHaveLength(1); + }); + + it('auto-fetches spans for tool-desc with sessions trace source', async () => { + mockFetchSessionSpans.mockResolvedValue({ + spans: [ + { traceId: 't1', spanId: 's1', body: {} }, + { traceId: 't1', spanId: 's2', body: {} }, + ], + spanRecordCount: 1, + logRecordCount: 1, + }); + + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-autofetch', + status: 'COMPLETED', + }); + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-autofetch', + status: 'COMPLETED', + recommendationResult: {}, + }); + + await runRecommendationCommand({ + type: 'TOOL_DESCRIPTION_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + tools: ['add_numbers:Add two numbers together'], + traceSource: 'sessions', + sessionIds: ['session-abc'], + pollIntervalMs: 0, + }); + + expect(mockFetchSessionSpans).toHaveBeenCalledWith( + expect.objectContaining({ + region: 'us-east-1', + runtimeId: 'rt-abc123', + sessionId: 'session-abc', + }) + ); + + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const traces = callArgs.recommendationConfig.toolDescriptionRecommendationConfig.agentTraces; + expect(traces.sessionSpans).toHaveLength(2); + expect(traces.cloudwatchLogs).toBeUndefined(); + }); + + it('throws when auto-fetch returns zero spans', async () => { + mockFetchSessionSpans.mockResolvedValue({ + spans: [], + spanRecordCount: 0, + logRecordCount: 0, + }); + + const result = await runRecommendationCommand({ + type: 'TOOL_DESCRIPTION_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + tools: ['add_numbers:Add numbers'], + traceSource: 'sessions', + sessionIds: ['session-empty'], + pollIntervalMs: 0, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('No spans found'); + }); + + it('derives service name from runtimeId by stripping hash suffix', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-svc', + status: 'COMPLETED', + }); + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-svc', + status: 'COMPLETED', + recommendationResult: {}, + }); + + await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const serviceNames = + callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces.cloudwatchLogs.serviceNames; + // runtimeId 'rt-abc123' → service name 'rt.DEFAULT' (strips '-abc123' suffix) + expect(serviceNames[0]).toBe('rt.DEFAULT'); + }); + + it('auto-fetches spans for system-prompt with sessions trace source', async () => { + mockFetchSessionSpans.mockResolvedValue({ spans: [{ sessionId: 'sess-1', spans: [] }] }); + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-sid', + status: 'COMPLETED', + }); + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-sid', + status: 'COMPLETED', + recommendationResult: {}, + }); + + await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'sessions', + sessionIds: ['sess-1'], + pollIntervalMs: 0, + }); + + expect(mockFetchSessionSpans).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-1' })); + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const traces = callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces; + expect(traces.sessionSpans).toBeDefined(); + expect(traces.cloudwatchLogs).toBeUndefined(); + }); + + it('builds cloudwatch config with two log group ARNs', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-cw', + status: 'COMPLETED', + }); + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-cw', + status: 'COMPLETED', + recommendationResult: {}, + }); + + await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'cloudwatch', + lookbackDays: 3, + pollIntervalMs: 0, + }); + + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const cwConfig = callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces.cloudwatchLogs; + expect(cwConfig.logGroupArns).toHaveLength(2); + expect(cwConfig.logGroupArns[0]).toContain('/aws/bedrock-agentcore/runtimes/rt-abc123-DEFAULT'); + expect(cwConfig.logGroupArns[1]).toContain('aws/spans'); + expect(cwConfig.startTime).toBeDefined(); + expect(cwConfig.endTime).toBeDefined(); + }); + + it('extracts failure details from statusReasons and result error fields', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-fail-detail', + recommendationArn: 'arn:rec-fail-detail', + name: 'test', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + requestId: 'start-req-id', + }); + + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-fail-detail', + status: 'FAILED', + requestId: 'poll-req-id', + statusReasons: ['Insufficient trace data'], + recommendationResult: { + systemPromptRecommendationResult: { + errorCode: 'INSUFFICIENT_DATA', + errorMessage: 'Not enough traces to generate recommendation', + }, + }, + }); + + const result = await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: ['Builtin.Toxicity'], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Insufficient trace data'); + expect(result.error).toContain('INSUFFICIENT_DATA'); + expect(result.error).toContain('Not enough traces'); + // Request IDs are logged to file only, not included in the error message + }); + + it('passes full ARN evaluator as-is', async () => { + mockStartRecommendation.mockResolvedValue({ + recommendationId: 'rec-arn', + status: 'COMPLETED', + }); + mockGetRecommendation.mockResolvedValue({ + recommendationId: 'rec-arn', + status: 'COMPLETED', + recommendationResult: {}, + }); + + const fullArn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:evaluator/custom-eval'; + await runRecommendationCommand({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'MyAgent', + evaluators: [fullArn], + inputSource: 'inline', + inlineContent: 'test', + traceSource: 'cloudwatch', + pollIntervalMs: 0, + }); + + const callArgs = mockStartRecommendation.mock.calls[0]![0]; + const evaluators = callArgs.recommendationConfig.systemPromptRecommendationConfig.evaluationConfig.evaluators; + expect(evaluators[0].evaluatorArn).toBe(fullArn); + }); +}); diff --git a/src/cli/operations/recommendation/apply-to-bundle.ts b/src/cli/operations/recommendation/apply-to-bundle.ts new file mode 100644 index 000000000..bf9060d10 --- /dev/null +++ b/src/cli/operations/recommendation/apply-to-bundle.ts @@ -0,0 +1,140 @@ +/** + * Syncs local agentcore.json after the server applies a recommendation to a + * config bundle. + * + * When a recommendation uses a config bundle as input, the server automatically + * creates a new bundle version with the recommended changes applied. The + * recommendation result includes the new version's bundleArn and versionId. + * + * This module fetches that new version via GetConfigurationBundleVersion and + * updates the local agentcore.json components to match the server state. + */ +import { ConfigIO } from '../../../lib'; +import { getConfigurationBundleVersion } from '../../aws/agentcore-config-bundles'; +import type { RecommendationResult } from '../../aws/agentcore-recommendation'; + +export interface ApplyRecommendationOptions { + /** Config bundle name in agentcore.json (used by CLI) */ + bundleName?: string; + /** Config bundle ARN (used by TUI — resolved to name via deployed state) */ + bundleArn?: string; + /** The recommendation result from the API (contains new bundle version info) */ + result: RecommendationResult; + /** AWS region for fetching the new bundle version */ + region: string; +} + +export interface ApplyRecommendationResult { + success: boolean; + error?: string; + /** New version ID that was synced from the server */ + newVersionId?: string; +} + +/** + * Extract the bundleId from a bundle ARN. + * ARN format: arn:aws:bedrock-agentcore:{region}:{account}:configuration-bundle/{bundleId} + */ +function extractBundleIdFromArn(arn: string): string | undefined { + const match = /configuration-bundle\/(.+)$/.exec(arn); + return match?.[1]; +} + +/** + * Sync local agentcore.json after the server creates a new config bundle version + * from a recommendation. Fetches the new version and updates local components. + */ +export async function applyRecommendationToBundle( + options: ApplyRecommendationOptions, + configIO: ConfigIO = new ConfigIO() +): Promise { + const { result, region } = options; + + // Extract the new bundle version from the recommendation result + const resultBundle = + result.systemPromptRecommendationResult?.configurationBundle ?? + result.toolDescriptionRecommendationResult?.configurationBundle; + + if (!resultBundle) { + return { + success: false, + error: + 'Recommendation result does not contain a new config bundle version. The server may not have applied the recommendation to the bundle.', + }; + } + + const bundleId = extractBundleIdFromArn(resultBundle.bundleArn); + if (!bundleId) { + return { + success: false, + error: `Could not extract bundle ID from ARN: ${resultBundle.bundleArn}`, + }; + } + + // Fetch the new version from the server + const newVersion = await getConfigurationBundleVersion({ + region, + bundleId, + versionId: resultBundle.versionId, + }); + + // Read current project spec and deployed state + const [spec, deployedState] = await Promise.all([configIO.readProjectSpec(), configIO.readDeployedState()]); + + // Find the target bundle by name or by matching ARN in deployed state + let bundleName: string | undefined; + if (options.bundleName) { + bundleName = options.bundleName; + } else if (options.bundleArn) { + // TUI stores the ARN — resolve to bundle name via deployed state + for (const targetName of Object.keys(deployedState.targets ?? {})) { + const target = deployedState.targets?.[targetName]; + const bundles = target?.resources?.configBundles; + if (bundles) { + for (const [name, state] of Object.entries(bundles)) { + if (state.bundleArn === options.bundleArn) { + bundleName = name; + break; + } + } + } + if (bundleName) break; + } + } + + const identifier = bundleName ?? options.bundleArn ?? 'unknown'; + const bundle = bundleName ? spec.configBundles?.find(cb => cb.name === bundleName) : undefined; + if (!bundle) { + return { + success: false, + error: `Config bundle "${identifier}" not found in agentcore.json.`, + }; + } + + // Update local bundle components to match the server's new version + bundle.components = newVersion.components as typeof bundle.components; + + // Update commit message from lineage metadata if available + if (newVersion.lineageMetadata?.commitMessage) { + bundle.commitMessage = newVersion.lineageMetadata.commitMessage; + } + + // Write updated spec + await configIO.writeProjectSpec(spec); + + // Update deployed state with the new version ID + for (const targetName of Object.keys(deployedState.targets ?? {})) { + const target = deployedState.targets?.[targetName]; + const bundleState = target?.resources?.configBundles?.[identifier]; + if (bundleState) { + bundleState.versionId = resultBundle.versionId; + break; + } + } + await configIO.writeDeployedState(deployedState); + + return { + success: true, + newVersionId: resultBundle.versionId, + }; +} diff --git a/src/cli/operations/recommendation/constants.ts b/src/cli/operations/recommendation/constants.ts new file mode 100644 index 000000000..c79647c44 --- /dev/null +++ b/src/cli/operations/recommendation/constants.ts @@ -0,0 +1,11 @@ +/** Polling interval in ms for checking recommendation status. */ +export const DEFAULT_POLL_INTERVAL_MS = 5000; + +/** Statuses that indicate a recommendation has reached a terminal state. */ +export const TERMINAL_STATUSES = new Set(['COMPLETED', 'SUCCEEDED', 'FAILED', 'DELETING']); + +/** Max retries for transient poll failures (network errors, 5xx). */ +export const MAX_POLL_RETRIES = 3; + +/** Max total polling duration in ms (30 minutes). */ +export const MAX_POLL_DURATION_MS = 30 * 60 * 1000; diff --git a/src/cli/operations/recommendation/fetch-session-spans.ts b/src/cli/operations/recommendation/fetch-session-spans.ts new file mode 100644 index 000000000..db5e63911 --- /dev/null +++ b/src/cli/operations/recommendation/fetch-session-spans.ts @@ -0,0 +1,158 @@ +/** + * Fetches OTEL span records and log records from CloudWatch for a given session, + * combining them into a SessionSpan[] suitable for inline `sessionSpans` in the + * Recommendation API. + * + * Tool description recommendations require inline sessionSpans (the server-side + * Lambda does NOT support `cloudwatchLogs` for this type). The OTEL mapper needs + * BOTH: + * - Span records from the `aws/spans` log group + * - Log records (with body.input/output.messages) from the runtime log group + * + * Without log records the mapper produces "zero trajectories". + */ +import type { SessionSpan } from '../../aws/agentcore-recommendation'; +import { searchLogs } from '../../aws/cloudwatch'; + +export interface FetchSessionSpansOptions { + /** AWS region */ + region: string; + /** Agent runtime ID, e.g. "myproject_MyAgent-QMd093Gl4O" */ + runtimeId: string; + /** Session ID to filter spans for */ + sessionId: string; + /** Lookback days (default 7) */ + lookbackDays?: number; + /** Progress callback */ + onProgress?: (message: string) => void; +} + +export interface FetchSessionSpansResult { + spans: SessionSpan[]; + spanRecordCount: number; + logRecordCount: number; +} + +/** The log group where OTEL span records are stored (no leading slash). */ +const SPANS_LOG_GROUP = 'aws/spans'; + +/** + * Fetch session spans from both CloudWatch log groups and combine them. + * + * 1. Fetches span records from `aws/spans` filtered by session.id + * 2. Fetches log records from the runtime log group filtered by body+input + * 3. Filters log records client-side by matching session.id + * 4. Returns combined array + */ +export async function fetchSessionSpans(options: FetchSessionSpansOptions): Promise { + const { region, runtimeId, sessionId, lookbackDays = 7, onProgress } = options; + + const runtimeLogGroup = `/aws/bedrock-agentcore/runtimes/${runtimeId}-DEFAULT`; + const endTimeMs = Date.now(); + const startTimeMs = endTimeMs - lookbackDays * 24 * 60 * 60 * 1000; + + // Fetch span records and log records in parallel + onProgress?.('Fetching span records from aws/spans...'); + const [spanRecords, logRecords] = await Promise.all([ + collectLogEvents({ + logGroupName: SPANS_LOG_GROUP, + region, + startTimeMs, + endTimeMs, + filterPattern: `"session.id" "${sessionId}"`, + }), + collectLogEvents({ + logGroupName: runtimeLogGroup, + region, + startTimeMs, + endTimeMs, + // Filter for log records that contain body with input messages + filterPattern: `"body" "input"`, + }), + ]); + + onProgress?.(`Found ${spanRecords.length} span records, ${logRecords.length} log record candidates`); + + // Parse span records — these are already OTEL spans with attributes.session.id + const spans: SessionSpan[] = []; + for (const event of spanRecords) { + try { + const parsed = JSON.parse(event.message) as SessionSpan; + spans.push(parsed); + } catch { + // Skip unparseable records + } + } + + // Parse and filter log records — keep only those matching our session + let logRecordCount = 0; + for (const event of logRecords) { + try { + const parsed = JSON.parse(event.message) as Record; + if (matchesSession(parsed, sessionId)) { + spans.push(parsed as unknown as SessionSpan); + logRecordCount++; + } + } catch { + // Skip unparseable records + } + } + + onProgress?.( + `Combined ${spans.length} spans (${spans.length - logRecordCount} span records + ${logRecordCount} log records)` + ); + + return { + spans, + spanRecordCount: spans.length - logRecordCount, + logRecordCount, + }; +} + +/** + * Check if a parsed log record matches the target session ID. + * Log records may have session.id in attributes or in the traceId/body context. + */ +function matchesSession(record: Record, sessionId: string): boolean { + // Check attributes.session.id (most common) + const attrs = record.attributes as Record | undefined; + if (attrs?.['session.id'] === sessionId) return true; + + // Check nested body for session references + const body = record.body as Record | undefined; + if (body) { + const bodyStr = JSON.stringify(body); + if (bodyStr.includes(sessionId)) return true; + } + + return false; +} + +/** + * Collect all log events from a CloudWatch log group into an array. + * Uses the existing searchLogs async generator. + */ +async function collectLogEvents(options: { + logGroupName: string; + region: string; + startTimeMs: number; + endTimeMs: number; + filterPattern: string; +}): Promise<{ timestamp: number; message: string }[]> { + const events: { timestamp: number; message: string }[] = []; + + try { + for await (const event of searchLogs(options)) { + events.push(event); + } + } catch (err) { + // Log group may not exist yet (e.g. no invocations) — return empty + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('ResourceNotFoundException') || msg.includes('does not exist')) { + return []; + } + throw err; + } + + return events; +} diff --git a/src/cli/operations/recommendation/index.ts b/src/cli/operations/recommendation/index.ts new file mode 100644 index 000000000..f60a1d798 --- /dev/null +++ b/src/cli/operations/recommendation/index.ts @@ -0,0 +1,18 @@ +export { applyRecommendationToBundle } from './apply-to-bundle'; +export type { ApplyRecommendationOptions, ApplyRecommendationResult } from './apply-to-bundle'; +export { fetchSessionSpans } from './fetch-session-spans'; +export type { FetchSessionSpansOptions, FetchSessionSpansResult } from './fetch-session-spans'; +export { runRecommendationCommand } from './run-recommendation'; +export type { + RunRecommendationCommandOptions, + RunRecommendationCommandResult, + RecommendationType, + RecommendationInputSourceKind, + TraceSourceKind, +} from './types'; +export { + saveRecommendationRun, + loadRecommendationRun, + listAllRecommendations, + type RecommendationRunRecord, +} from './recommendation-storage'; diff --git a/src/cli/operations/recommendation/recommendation-storage.ts b/src/cli/operations/recommendation/recommendation-storage.ts new file mode 100644 index 000000000..ad8aa7160 --- /dev/null +++ b/src/cli/operations/recommendation/recommendation-storage.ts @@ -0,0 +1,84 @@ +import { findConfigRoot } from '../../../lib'; +import type { RecommendationResult, RecommendationType } from '../../aws/agentcore-recommendation'; +import type { RunRecommendationCommandResult } from './types'; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +export const RECOMMENDATIONS_DIR = 'recommendations'; + +export interface RecommendationRunRecord { + recommendationId: string; + type: RecommendationType; + agent: string; + evaluators: string[]; + status: string; + startedAt?: string; + completedAt?: string; + result?: RecommendationResult; +} + +function getRecommendationResultsDir(): string { + const configRoot = findConfigRoot(); + if (!configRoot) { + throw new Error('No agentcore project found. Run `agentcore create` first.'); + } + return join(configRoot, '.cli', RECOMMENDATIONS_DIR); +} + +export function saveRecommendationRun( + recommendationId: string, + result: RunRecommendationCommandResult, + type: RecommendationType, + agent: string, + evaluators: string[] +): string { + const dir = getRecommendationResultsDir(); + mkdirSync(dir, { recursive: true }); + + const filePath = join(dir, `${recommendationId}.json`); + + const record: RecommendationRunRecord = { + recommendationId, + type, + agent, + evaluators, + status: result.status ?? 'unknown', + startedAt: result.startedAt, + completedAt: result.completedAt, + result: result.result, + }; + + writeFileSync(filePath, JSON.stringify(record, null, 2)); + return filePath; +} + +export function loadRecommendationRun(recommendationId: string): RecommendationRunRecord { + const dir = getRecommendationResultsDir(); + const jsonName = recommendationId.endsWith('.json') ? recommendationId : `${recommendationId}.json`; + const filePath = join(dir, jsonName); + + if (!existsSync(filePath)) { + throw new Error(`Recommendation "${recommendationId}" not found at ${filePath}`); + } + + return JSON.parse(readFileSync(filePath, 'utf-8')) as RecommendationRunRecord; +} + +export function listAllRecommendations(): RecommendationRunRecord[] { + const configRoot = findConfigRoot(); + if (!configRoot) { + throw new Error('No agentcore project found. Run `agentcore create` first.'); + } + + const dir = join(configRoot, '.cli', RECOMMENDATIONS_DIR); + if (!existsSync(dir)) { + return []; + } + + const files = readdirSync(dir) + .filter(f => f.endsWith('.json')) + .sort() + .reverse(); + + return files.map(f => JSON.parse(readFileSync(join(dir, f), 'utf-8')) as RecommendationRunRecord); +} diff --git a/src/cli/operations/recommendation/run-recommendation.ts b/src/cli/operations/recommendation/run-recommendation.ts new file mode 100644 index 000000000..0423cfe32 --- /dev/null +++ b/src/cli/operations/recommendation/run-recommendation.ts @@ -0,0 +1,610 @@ +/** + * Orchestrates running a Recommendation: + * 1. Resolve agent and evaluator from project + * 2. Build recommendationConfig from CLI inputs + * 3. Call StartRecommendation (creates resource, returns 202) + * 4. Poll GetRecommendation until terminal status + * 5. Return result with optimized artifact + */ +import { ConfigIO } from '../../../lib'; +import type { DeployedState } from '../../../schema'; +import type { + RecommendationConfig, + RecommendationResult, + RecommendationType, + SessionSpan, +} from '../../aws/agentcore-recommendation'; +import { getRecommendation, startRecommendation } from '../../aws/agentcore-recommendation'; +import { arnPrefix } from '../../aws/partition'; +import { detectRegion } from '../../aws/region'; +import { ExecLogger } from '../../logging/exec-logger'; +import { DEFAULT_POLL_INTERVAL_MS, MAX_POLL_DURATION_MS, MAX_POLL_RETRIES, TERMINAL_STATUSES } from './constants'; +import { fetchSessionSpans } from './fetch-session-spans'; +import type { RunRecommendationCommandOptions, RunRecommendationCommandResult } from './types'; +import { readFileSync } from 'fs'; + +export async function runRecommendationCommand( + options: RunRecommendationCommandOptions +): Promise { + const { pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, onProgress } = options; + let logger: ExecLogger | undefined; + try { + logger = new ExecLogger({ command: 'recommend' }); + } catch { + // Logger creation can fail in tests or when no project root exists — non-fatal + } + + try { + logger?.startStep('Load project config'); + // 1. Read project config and deployed state + const configIO = new ConfigIO(); + const [projectSpec, deployedState, awsTargets] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readDeployedState(), + configIO.resolveAWSDeploymentTargets(), + ]); + + const targetRegion = awsTargets.length > 0 ? awsTargets[0]!.region : undefined; + const { region: detectedRegion } = await detectRegion(); + const region = options.region ?? targetRegion ?? detectedRegion; + const stage = process.env.AGENTCORE_STAGE?.toLowerCase() ?? 'prod'; + logger?.log(`Region: ${region}, Stage: ${stage}`); + logger?.endStep('success'); + + // 2. Resolve agent from deployed state (needed for log group ARNs) + logger?.startStep('Resolve agent and evaluators'); + const agentState = resolveAgentState(deployedState, options.agent); + if (!agentState) { + logger?.log(`Agent "${options.agent}" not found in deployed state`, 'error'); + logger?.endStep('error', `Agent "${options.agent}" not deployed`); + logger?.finalize(false); + return { + success: false, + error: `Agent "${options.agent}" not deployed. Run \`agentcore deploy\` first.`, + logFilePath: logger?.logFilePath, + }; + } + logger?.log(`Agent: ${options.agent} (runtime: ${agentState.runtimeId})`); + + // 3. Resolve evaluator ID/ARN (API accepts exactly one for system-prompt, none for tool-desc) + const evaluatorIds: string[] = []; + for (const evaluator of options.evaluators) { + const evaluatorId = resolveEvaluatorId(deployedState, evaluator, region); + if (!evaluatorId) { + return { + success: false, + error: `Evaluator "${evaluator}" not found in deployed state. Use a Builtin.* name, a full ARN, or deploy a custom evaluator first.`, + logFilePath: logger?.logFilePath, + }; + } + evaluatorIds.push(evaluatorId); + } + if (options.type === 'SYSTEM_PROMPT_RECOMMENDATION' && evaluatorIds.length !== 1) { + return { + success: false, + error: 'System prompt recommendations require exactly one evaluator.', + logFilePath: logger?.logFilePath, + }; + } + logger?.log(`Evaluators: ${evaluatorIds.join(', ') || '(none)'}`); + logger?.endStep('success'); + + // 4. Read input content (if from file) + let inlineContent: string | undefined; + if (options.inputSource === 'file' && options.promptFile) { + inlineContent = readFileSync(options.promptFile, 'utf-8'); + } else if (options.inputSource === 'inline') { + inlineContent = options.inlineContent; + } + + // Validate that system prompt content is non-empty (API rejects empty text) + if ( + options.type === 'SYSTEM_PROMPT_RECOMMENDATION' && + options.inputSource !== 'config-bundle' && + !inlineContent?.trim() + ) { + return { + success: false, + error: 'System prompt content is required. Provide via --inline, --prompt-file, or --bundle-name.', + logFilePath: logger?.logFilePath, + }; + } + + // 5. Extract account ID from agent runtime ARN + const accountId = extractAccountIdFromArn(agentState.runtimeArn); + + // 5b. Resolve config bundle ARN from deployed state (if using config bundle) + let bundleArn: string | undefined; + if (options.inputSource === 'config-bundle' && options.bundleName) { + if (options.bundleName.startsWith('arn:')) { + // Already an ARN (e.g. from TUI which stores the ARN directly) + bundleArn = options.bundleName; + } else { + // Human-readable name (e.g. from CLI --bundle-name flag) — resolve from deployed state + for (const targetName of Object.keys(deployedState.targets ?? {})) { + const target = deployedState.targets?.[targetName]; + const bundle = target?.resources?.configBundles?.[options.bundleName]; + if (bundle?.bundleArn) { + bundleArn = bundle.bundleArn; + break; + } + } + if (!bundleArn) { + return { + success: false, + error: `Config bundle "${options.bundleName}" not found in deployed state. Run \`agentcore deploy\` first.`, + logFilePath: logger?.logFilePath, + }; + } + } + logger?.log(`Resolved bundle ARN: ${bundleArn}`); + } + + // 5c. Resolve short-form systemPromptJsonPath (e.g. "systemPrompt") to full JSONPath + let resolvedSystemPromptJsonPath = options.systemPromptJsonPath; + if ( + options.inputSource === 'config-bundle' && + options.bundleName && + resolvedSystemPromptJsonPath && + !resolvedSystemPromptJsonPath.startsWith('$') + ) { + // User provided a short field name like "systemPrompt" — resolve from agentcore.json + const bundleName = options.bundleName.startsWith('arn:') + ? // Find bundle name from ARN by matching deployed state + Object.values(deployedState.targets) + .flatMap(t => Object.entries(t.resources?.configBundles ?? {})) + .find(([, b]) => b.bundleArn === options.bundleName)?.[0] + : options.bundleName; + + if (bundleName) { + const projBundle = projectSpec.configBundles?.find(b => b.name === bundleName); + if (projBundle?.components) { + const subPath = resolvedSystemPromptJsonPath; + // Use the first component key, resolved to a real ARN + const firstComponentKey = Object.keys(projBundle.components)[0]; + if (firstComponentKey) { + const resolvedKey = resolveComponentKeyForJsonPath(firstComponentKey, deployedState); + resolvedSystemPromptJsonPath = `$.${resolvedKey}.configuration.${subPath}`; + logger?.log(`Resolved short JSONPath "${subPath}" → "${resolvedSystemPromptJsonPath}"`); + } + } + } + } + + // 6. Build recommendationConfig based on type + const recommendationConfig = await buildRecommendationConfig({ + type: options.type, + inlineContent, + bundleArn, + bundleVersion: options.bundleVersion, + systemPromptJsonPath: resolvedSystemPromptJsonPath, + toolDescJsonPaths: options.toolDescJsonPaths, + inputSource: options.inputSource, + tools: options.tools, + traceSource: options.traceSource, + lookbackDays: options.lookbackDays, + sessionIds: options.sessionIds, + spansFile: options.spansFile, + runtimeId: agentState.runtimeId, + accountId, + region, + evaluatorIds, + onProgress, + logger, + }); + + // 7. Start the recommendation + logger?.startStep('Start recommendation'); + const recommendationName = options.recommendationName ?? `${projectSpec.name}_${options.agent}_${Date.now()}`; + onProgress?.('starting', `Starting recommendation "${recommendationName}"...`); + + const startPayload = { + region, + name: recommendationName, + type: options.type, + recommendationConfig, + }; + logger?.log(`Request payload:\n${JSON.stringify(startPayload, null, 2)}`); + + const startResult = await startRecommendation(startPayload); + + logger?.log(`Response: ${JSON.stringify(startResult, null, 2)}`); + logger?.endStep('success'); + onProgress?.('started', `Recommendation created: ${startResult.recommendationId} (status: ${startResult.status})`); + options.onStarted?.({ recommendationId: startResult.recommendationId, region }); + + // 8. Poll GetRecommendation until terminal status + logger?.startStep('Poll for completion'); + const maxDurationMs = options.maxPollDurationMs ?? MAX_POLL_DURATION_MS; + const pollStartTime = Date.now(); + let currentStatus = startResult.status; + let consecutiveFailures = 0; + + while (!TERMINAL_STATUSES.has(currentStatus)) { + await sleep(pollIntervalMs); + + // Check max poll duration + if (Date.now() - pollStartTime > maxDurationMs) { + logger?.log(`Max poll duration (${maxDurationMs}ms) exceeded`, 'error'); + logger?.endStep('error', 'Poll timeout'); + logger?.finalize(false); + return { + success: false, + error: `Polling timed out after ${Math.round(maxDurationMs / 60000)} minutes. The recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}`, + recommendationId: startResult.recommendationId, + status: currentStatus, + logFilePath: logger?.logFilePath, + }; + } + + // Poll with retry for transient failures + let pollResult; + try { + pollResult = await getRecommendation({ + region, + recommendationId: startResult.recommendationId, + }); + consecutiveFailures = 0; + } catch (pollErr) { + consecutiveFailures++; + const pollErrMsg = pollErr instanceof Error ? pollErr.message : String(pollErr); + logger?.log(`Poll attempt failed (${consecutiveFailures}/${MAX_POLL_RETRIES}): ${pollErrMsg}`, 'error'); + + if (consecutiveFailures >= MAX_POLL_RETRIES) { + logger?.endStep('error', `${MAX_POLL_RETRIES} consecutive poll failures`); + logger?.finalize(false); + return { + success: false, + error: `Polling failed after ${MAX_POLL_RETRIES} consecutive errors: ${pollErrMsg}\nThe recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}`, + recommendationId: startResult.recommendationId, + status: currentStatus, + logFilePath: logger?.logFilePath, + }; + } + onProgress?.('polling', `Poll error, retrying (${consecutiveFailures}/${MAX_POLL_RETRIES})...`); + continue; + } + + currentStatus = pollResult.status; + onProgress?.('polling', `Status: ${currentStatus}`); + + if (TERMINAL_STATUSES.has(currentStatus)) { + if (currentStatus === 'COMPLETED' || currentStatus === 'SUCCEEDED') { + logger?.log(`Completed. Result:\n${JSON.stringify(pollResult.recommendationResult, null, 2)}`); + logger?.endStep('success'); + logger?.finalize(true); + return { + success: true, + recommendationId: startResult.recommendationId, + status: currentStatus, + result: pollResult.recommendationResult, + region, + startedAt: pollResult.createdAt, + completedAt: pollResult.completedAt, + logFilePath: logger?.logFilePath, + }; + } + + // Extract error details from the FAILED response + const failureDetails = extractFailureDetails(pollResult); + logger?.log(`Terminal status: ${currentStatus}`, 'error'); + logger?.log(`Full poll response:\n${JSON.stringify(pollResult, null, 2)}`, 'error'); + if (failureDetails) logger?.log(`Failure details: ${failureDetails}`, 'error'); + logger?.endStep('error', `Status: ${currentStatus}`); + logger?.finalize(false); + // Log request IDs for debugging (only in log file, not shown in TUI) + const requestIds = [ + startResult.requestId ? `Start: ${startResult.requestId}` : '', + pollResult.requestId ? `Poll: ${pollResult.requestId}` : '', + ] + .filter(Boolean) + .join(', '); + if (requestIds) logger?.log(`Request IDs: ${requestIds}`, 'error'); + + return { + success: false, + error: failureDetails + ? `Recommendation failed: ${failureDetails}` + : `Recommendation finished with status: ${currentStatus}`, + recommendationId: startResult.recommendationId, + status: currentStatus, + logFilePath: logger?.logFilePath, + }; + } + } + + // Should not reach here, but handle gracefully + logger?.log(`Unexpected terminal status: ${currentStatus}`, 'error'); + logger?.endStep('error', `Unexpected status: ${currentStatus}`); + logger?.finalize(false); + return { + success: false, + error: `Recommendation ended with unexpected status: ${currentStatus}`, + recommendationId: startResult.recommendationId, + status: currentStatus, + logFilePath: logger?.logFilePath, + }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + logger?.log(`Error: ${errorMsg}`, 'error'); + logger?.endStep('error', errorMsg); + logger?.finalize(false); + return { + success: false, + error: errorMsg, + logFilePath: logger?.logFilePath, + }; + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function resolveAgentState( + deployedState: DeployedState, + agentName: string +): { runtimeId: string; runtimeArn: string } | undefined { + for (const target of Object.values(deployedState.targets)) { + const agent = target.resources?.runtimes?.[agentName]; + if (agent) return agent; + } + return undefined; +} + +/** + * Resolve an evaluator name to a full ARN. + * Returns undefined if the evaluator cannot be resolved. + */ +function resolveEvaluatorId(deployedState: DeployedState, evaluator: string, region: string): string | undefined { + // Already a full ARN — use as-is + if (evaluator.startsWith('arn:')) { + return evaluator; + } + // Builtin shorthand → expand to full ARN + if (evaluator.startsWith('Builtin.')) { + return `${arnPrefix(region)}:bedrock-agentcore:::evaluator/${evaluator}`; + } + // Look up custom evaluator from deployed state + for (const target of Object.values(deployedState.targets)) { + const evalState = target.resources?.evaluators?.[evaluator]; + if (evalState) return evalState.evaluatorArn; + } + return undefined; +} + +/** + * Extract the 12-digit AWS account ID from an ARN. + * Falls back to '*' if the ARN format is unexpected. + */ +function extractAccountIdFromArn(arn: string): string { + const parts = arn.split(':'); + return parts[4] && /^\d{12}$/.test(parts[4]) ? parts[4] : '*'; +} + +interface BuildConfigOptions { + type: RecommendationType; + inlineContent?: string; + bundleArn?: string; + bundleVersion?: string; + systemPromptJsonPath?: string; + toolDescJsonPaths?: { toolName: string; toolDescriptionJsonPath: string }[]; + inputSource: string; + tools?: string[]; + traceSource: string; + lookbackDays?: number; + sessionIds?: string[]; + spansFile?: string; + runtimeId: string; + accountId: string; + region: string; + evaluatorIds: string[]; + onProgress?: (status: string, message: string) => void; + logger?: ExecLogger; +} + +async function buildRecommendationConfig(opts: BuildConfigOptions): Promise { + // Build agent traces — either from a spans file (inline session spans) or CloudWatch + let agentTraces; + + if (opts.traceSource === 'spans-file' && opts.spansFile) { + // Explicit spans file — read and use as inline sessionSpans + const spansContent = readFileSync(opts.spansFile, 'utf-8'); + const sessionSpans = JSON.parse(spansContent) as SessionSpan | SessionSpan[]; + agentTraces = { + sessionSpans: Array.isArray(sessionSpans) ? sessionSpans : [sessionSpans], + }; + } else if (opts.traceSource === 'sessions' && opts.sessionIds && opts.sessionIds.length > 0) { + // Session IDs selected — auto-fetch from both log groups and use inline sessionSpans. + // The CloudWatch trace config does not support filtering by multiple session IDs, + // so we fetch spans client-side and send them inline. + opts.onProgress?.('fetching-spans', 'Fetching session spans from CloudWatch...'); + opts.logger?.log( + 'Auto-fetching spans for selected sessions (CloudWatch config does not support session ID filtering)' + ); + + const allSpans = []; + for (const sessionId of opts.sessionIds) { + const result = await fetchSessionSpans({ + region: opts.region, + runtimeId: opts.runtimeId, + sessionId, + lookbackDays: opts.lookbackDays ?? 7, + onProgress: msg => { + opts.logger?.log(msg); + opts.onProgress?.('fetching-spans', msg); + }, + }); + allSpans.push(...result.spans); + } + + if (allSpans.length === 0) { + throw new Error( + 'No spans found for the specified session(s). Ensure the agent has been invoked and traces have propagated to CloudWatch (may take 5-10 minutes).' + ); + } + + opts.logger?.log(`Total spans fetched: ${allSpans.length}`); + opts.onProgress?.('fetching-spans', `Fetched ${allSpans.length} spans`); + agentTraces = { sessionSpans: allSpans }; + } else { + // Lookback-based path — use cloudwatchLogs with time range + const runtimeLogGroupArn = `${arnPrefix(opts.region)}:logs:${opts.region}:${opts.accountId}:log-group:/aws/bedrock-agentcore/runtimes/${opts.runtimeId}-DEFAULT`; + const spansLogGroupArn = `${arnPrefix(opts.region)}:logs:${opts.region}:${opts.accountId}:log-group:aws/spans`; + + // Derive service name: strip the random hash suffix from runtimeId + // runtimeId format: {project}_{agent}-{hash} → serviceName: {project}_{agent}.DEFAULT + const serviceName = opts.runtimeId.replace(/-[^-]+$/, '.DEFAULT'); + + const lookbackDays = opts.lookbackDays ?? 7; + agentTraces = { + cloudwatchLogs: { + logGroupArns: [runtimeLogGroupArn, spansLogGroupArn], + serviceNames: [serviceName], + startTime: new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString(), + endTime: new Date().toISOString(), + }, + }; + } + + const evaluationConfig: import('../../aws/agentcore-recommendation').RecommendationEvaluationConfig = { + evaluators: [{ evaluatorArn: opts.evaluatorIds[0]! }], + }; + + // Validate required fields for config-bundle source (API requires all three) + if (opts.inputSource === 'config-bundle' && opts.bundleArn && !opts.bundleVersion) { + throw new Error('Config bundle version is required. Provide --bundle-version or deploy the bundle first.'); + } + + if (opts.inputSource === 'config-bundle' && opts.bundleArn) { + if (opts.type === 'SYSTEM_PROMPT_RECOMMENDATION' && !opts.systemPromptJsonPath) { + throw new Error( + 'Config bundle requires --system-prompt-json-path to locate the system prompt field.\n' + + "Use the field name (e.g. --system-prompt-json-path 'systemPrompt') and it will be resolved from agentcore.json.\n" + + "Or provide the full JSONPath (e.g. '$.ARN.configuration.systemPrompt')." + ); + } + if (opts.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && !opts.toolDescJsonPaths?.length) { + throw new Error( + 'Config bundle requires --tool-desc-json-path to locate tool description fields.\n' + + "Example: --tool-desc-json-path 'toolName:$.ARN.configuration.toolDescription'" + ); + } + } + + if (opts.type === 'SYSTEM_PROMPT_RECOMMENDATION') { + return { + systemPromptRecommendationConfig: { + systemPrompt: + opts.inputSource === 'config-bundle' && opts.bundleArn + ? { + configurationBundle: { + bundleArn: opts.bundleArn, + versionId: opts.bundleVersion!, + systemPromptJsonPath: opts.systemPromptJsonPath, + }, + } + : { text: opts.inlineContent ?? '' }, + agentTraces, + evaluationConfig, + }, + }; + } + + // TOOL_DESCRIPTION_RECOMMENDATION + if (opts.inputSource === 'config-bundle' && opts.bundleArn && opts.toolDescJsonPaths?.length) { + // Config bundle source — pass bundle reference with JSON paths for server-side resolution + return { + toolDescriptionRecommendationConfig: { + toolDescription: { + configurationBundle: { + bundleArn: opts.bundleArn, + versionId: opts.bundleVersion!, + tools: opts.toolDescJsonPaths, + }, + }, + agentTraces, + }, + }; + } + + // Inline/file source — parse "toolName:description" pairs from tools array + const toolEntries = (opts.tools ?? []).map(t => { + const colonIdx = t.indexOf(':'); + if (colonIdx > 0) { + return { toolName: t.slice(0, colonIdx), toolDescription: { text: t.slice(colonIdx + 1) } }; + } + return { toolName: t, toolDescription: { text: opts.inlineContent ?? '' } }; + }); + + return { + toolDescriptionRecommendationConfig: { + toolDescription: { + toolDescriptionText: { + tools: toolEntries, + }, + }, + agentTraces, + }, + }; +} + +/** + * Extract error details from a FAILED recommendation response. + * The API populates errorCode/errorMessage in the result, and statusReasons at top level. + */ +function extractFailureDetails(pollResult: { + statusReasons?: string[]; + recommendationResult?: RecommendationResult; +}): string | undefined { + const parts: string[] = []; + + if (pollResult.statusReasons?.length) { + parts.push(pollResult.statusReasons.join('; ')); + } + + const result = pollResult.recommendationResult; + if (result) { + const errorSource = result.systemPromptRecommendationResult ?? result.toolDescriptionRecommendationResult; + if (errorSource) { + if (errorSource.errorCode) parts.push(`[${errorSource.errorCode}]`); + if (errorSource.errorMessage) parts.push(errorSource.errorMessage); + } + } + + return parts.length > 0 ? parts.join(' ') : undefined; +} + +/** + * Resolve a component key (which may be a placeholder like {{runtime:name}}) + * to its real ARN from deployed state. Returns the key unchanged if not a placeholder. + */ +function resolveComponentKeyForJsonPath(key: string, deployedState: DeployedState): string { + if (key.startsWith('arn:')) return key; + + const rtMatch = /^\{\{runtime:(.+)\}\}$/.exec(key); + if (rtMatch) { + const rtName = rtMatch[1]!; + for (const target of Object.values(deployedState.targets)) { + const rt = target.resources?.runtimes?.[rtName]; + if (rt) return rt.runtimeArn; + } + } + + const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(key); + if (gwMatch) { + const gwName = gwMatch[1]!; + for (const target of Object.values(deployedState.targets)) { + const httpGw = target.resources?.httpGateways?.[gwName]; + if (httpGw) return httpGw.gatewayArn; + const mcpGw = target.resources?.mcp?.gateways?.[gwName]; + if (mcpGw) return mcpGw.gatewayArn; + } + } + + return key; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/cli/operations/recommendation/types.ts b/src/cli/operations/recommendation/types.ts new file mode 100644 index 000000000..426ba84a8 --- /dev/null +++ b/src/cli/operations/recommendation/types.ts @@ -0,0 +1,72 @@ +/** + * Shared types for the recommendation feature. + */ +import type { RecommendationResult, RecommendationType } from '../../aws/agentcore-recommendation'; + +export type { RecommendationType } from '../../aws/agentcore-recommendation'; + +/** CLI-facing input source kind (maps to API config shape). */ +export type RecommendationInputSourceKind = 'config-bundle' | 'inline' | 'file'; + +/** CLI-facing trace source kind (maps to API agentTraces shape). */ +export type TraceSourceKind = 'cloudwatch' | 'sessions' | 'spans-file'; + +export interface RunRecommendationCommandOptions { + /** What to optimize */ + type: RecommendationType; + /** Agent name (from project) */ + agent: string; + /** Evaluator name, Builtin.* ID, or ARN (API accepts exactly one for system-prompt) */ + evaluators: string[]; + /** Input source kind */ + inputSource: RecommendationInputSourceKind; + /** Config bundle name (when inputSource is 'config-bundle') */ + bundleName?: string; + /** Config bundle version (when inputSource is 'config-bundle') */ + bundleVersion?: string; + /** JSONPath to the system prompt field within the config bundle (when inputSource is 'config-bundle') */ + systemPromptJsonPath?: string; + /** Tool name → JSONPath pairs for tool descriptions within the config bundle (when inputSource is 'config-bundle') */ + toolDescJsonPaths?: { toolName: string; toolDescriptionJsonPath: string }[]; + /** Inline content (when inputSource is 'inline') */ + inlineContent?: string; + /** File path (when inputSource is 'file') */ + promptFile?: string; + /** Specific tool names and descriptions (for TOOL_DESCRIPTION_RECOMMENDATION) */ + tools?: string[]; + /** Trace source kind */ + traceSource: TraceSourceKind; + /** Lookback days (when traceSource is 'cloudwatch') */ + lookbackDays?: number; + /** Session IDs (when traceSource is 'sessions') — used to filter CloudWatch traces */ + sessionIds?: string[]; + /** Path to JSON file containing session spans (when traceSource is 'spans-file') */ + spansFile?: string; + /** Region override */ + region?: string; + /** Optional recommendation name */ + recommendationName?: string; + /** Poll interval in ms */ + pollIntervalMs?: number; + /** Max polling duration in ms before timing out */ + maxPollDurationMs?: number; + /** Progress callback */ + onProgress?: (status: string, message: string) => void; + /** Called once the recommendation has been created, with ID and region for cancellation */ + onStarted?: (info: { recommendationId: string; region: string }) => void; +} + +export interface RunRecommendationCommandResult { + success: boolean; + error?: string; + recommendationId?: string; + status?: string; + /** The recommendation result from the API (populated on COMPLETED) */ + result?: RecommendationResult; + /** Resolved AWS region used for the recommendation */ + region?: string; + startedAt?: string; + completedAt?: string; + /** Path to the execution log file */ + logFilePath?: string; +} 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; +} diff --git a/src/cli/primitives/ABTestPrimitive.ts b/src/cli/primitives/ABTestPrimitive.ts new file mode 100644 index 000000000..9dd973571 --- /dev/null +++ b/src/cli/primitives/ABTestPrimitive.ts @@ -0,0 +1,728 @@ +import { findConfigRoot } from '../../lib'; +import type { ABTest } from '../../schema/schemas/primitives/ab-test'; +import { ABTestSchema } from '../../schema/schemas/primitives/ab-test'; +import { getErrorMessage } from '../errors'; +import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { requireTTY } from '../tui/guards/tty'; +import { BasePrimitive } from './BasePrimitive'; +import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +export type GatewayChoice = { type: 'create-new' } | { type: 'existing-http'; name: string }; + +export interface AddABTestOptions { + name: string; + description?: string; + agent: string; + gatewayChoice?: GatewayChoice; + roleArn?: string; + controlBundle: string; + controlVersion: string; + treatmentBundle: string; + treatmentVersion: string; + controlWeight: number; + treatmentWeight: number; + onlineEval: string; + trafficHeaderName?: string; + maxDurationDays?: number; + enableOnCreate?: boolean; +} + +export interface AddTargetBasedABTestOptions { + name: string; + description?: string; + gateway: string; + runtime: string; + roleArn?: string; + controlEndpoint: string; + treatmentEndpoint: string; + controlWeight: number; + treatmentWeight: number; + controlOnlineEval: string; + treatmentOnlineEval: string; + gatewayFilter?: string; + enableOnCreate?: boolean; +} + +export type RemovableABTest = RemovableResource; + +/** + * ABTestPrimitive handles all A/B test add/remove operations. + * + * A/B tests split traffic between two config bundle versions (control vs + * treatment) through a gateway, with online evaluation tracking performance. + * They are created via direct API calls (not CloudFormation) and stored in + * agentcore.json for lifecycle management. + */ +export class ABTestPrimitive extends BasePrimitive { + readonly kind = 'ab-test' as const; + readonly label = 'AB Test'; + override readonly article = 'an'; + readonly primitiveSchema = ABTestSchema; + + async add(options: AddABTestOptions): Promise> { + try { + const abTest = await this.createABTest(options); + return { success: true, abTestName: abTest.name }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + async remove(testName: string, options?: { deleteGateway?: boolean }): Promise { + try { + const project = await this.readProjectSpec(); + + const index = (project.abTests ?? []).findIndex(t => t.name === testName); + if (index === -1) { + return { success: false, error: `AB test "${testName}" not found.` }; + } + + const removedTest = project.abTests[index]!; + project.abTests.splice(index, 1); + + // Cascade: remove auto-created online eval configs for target-based tests + // Only remove eval configs that were auto-created (matching the {testName}_eval_ prefix pattern) + if (removedTest.mode === 'target-based' && 'perVariantOnlineEvaluationConfig' in removedTest.evaluationConfig) { + const autoCreatedPrefix = `${testName}_eval_`; + const evalNames = removedTest.evaluationConfig.perVariantOnlineEvaluationConfig + .map(pv => pv.onlineEvaluationConfigArn) + .filter(name => name.startsWith(autoCreatedPrefix)); + project.onlineEvalConfigs = project.onlineEvalConfigs.filter(c => !evalNames.includes(c.name)); + } + + // --delete-gateway: cascade remove gateway targets and orphaned gateways + if (options?.deleteGateway && removedTest.gatewayRef) { + const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(removedTest.gatewayRef); + if (gwMatch) { + const gwName = gwMatch[1]!; + + // Remove gateway targets that were created for this AB test's variants + if (removedTest.mode === 'target-based') { + const targetNames = removedTest.variants + .map(v => v.variantConfiguration.target?.targetName) + .filter((n): n is string => !!n); + const gw = (project.httpGateways ?? []).find(g => g.name === gwName); + if (gw?.targets) { + gw.targets = gw.targets.filter(t => !targetNames.includes(t.name)); + } + } + + // Remove gateway if no other AB tests reference it + const stillReferenced = (project.abTests ?? []).some(t => { + const m = /^\{\{gateway:(.+)\}\}$/.exec(t.gatewayRef); + return m?.[1] === gwName; + }); + if (!stillReferenced) { + project.httpGateways = (project.httpGateways ?? []).filter(gw => gw.name !== gwName); + } + } + } + + await this.writeProjectSpec(project); + + return { success: true }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + async previewRemove(testName: string): Promise { + const project = await this.readProjectSpec(); + + const abTest = (project.abTests ?? []).find(t => t.name === testName); + if (!abTest) { + throw new Error(`AB test "${testName}" not found.`); + } + + const summary: string[] = [`Removing AB test: ${testName}`]; + const schemaChanges: SchemaChange[] = []; + + const testIndex = (project.abTests ?? []).findIndex(t => t.name === testName); + const afterSpec = { + ...project, + abTests: (project.abTests ?? []).filter(t => t.name !== testName), + httpGateways: [...(project.httpGateways ?? [])], + }; + + // Check if the gateway would be orphaned + const test = (project.abTests ?? [])[testIndex]; + if (test?.gatewayRef) { + const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(test.gatewayRef); + if (gwMatch) { + const gwName = gwMatch[1]; + const otherTests = (project.abTests ?? []).filter((_, i) => i !== testIndex); + const stillReferenced = otherTests.some(t => { + const m = /^\{\{gateway:(.+)\}\}$/.exec(t.gatewayRef); + return m && m[1] === gwName; + }); + if (!stillReferenced) { + summary.push(`Also removing HTTP gateway: ${gwName} (no other AB tests reference it)`); + afterSpec.httpGateways = (project.httpGateways ?? []).filter(gw => gw.name !== gwName); + } + } + } + + schemaChanges.push({ + file: 'agentcore/agentcore.json', + before: project, + after: afterSpec, + }); + + return { summary, directoriesToDelete: [], schemaChanges }; + } + + async getRemovable(): Promise { + try { + const project = await this.readProjectSpec(); + return (project.abTests ?? []).map(t => ({ name: t.name })); + } catch { + return []; + } + } + + async getAllNames(): Promise { + try { + const project = await this.readProjectSpec(); + return (project.abTests ?? []).map(t => t.name); + } catch { + return []; + } + } + + registerCommands(addCmd: Command, removeCmd: Command): void { + const abTestCmd = addCmd + .command('ab-test') + .description('[preview] Add an A/B test to the project') + .option('--mode ', 'config-bundle (default) or target-based') + .option('--name ', 'AB test name') + .option('--description ', 'AB test description') + .option('--runtime ', 'Runtime agent to A/B test') + .option('--role-arn ', 'IAM role ARN (auto-created if not provided)') + .option('--control-bundle ', 'Control config bundle name or ARN') + .option('--control-version ', 'Control config bundle version') + .option('--treatment-bundle ', 'Treatment config bundle name or ARN') + .option('--treatment-version ', 'Treatment config bundle version') + .option('--control-endpoint ', 'Endpoint qualifier for control') + .option('--treatment-endpoint ', 'Endpoint qualifier for treatment') + .option('--control-weight ', 'Traffic weight for control (1-100)', parseInt) + .option('--treatment-weight ', 'Traffic weight for treatment (1-100)', parseInt) + .option('--gateway ', 'HTTP gateway name') + .option('--online-eval ', 'Online evaluation config name or ARN') + .option('--control-online-eval ', 'Eval config name or ARN for control') + .option('--treatment-online-eval ', 'Eval config name or ARN for treatment') + .option('--gateway-filter ', 'Path pattern for routing') + .option('--traffic-header ', 'Header name for traffic routing') + // Hidden deprecated aliases for backwards compatibility + .option('--control-qualifier ', '') + .option('--treatment-qualifier ', '') + // TODO(post-preview): Re-enable --max-duration once configurable duration is launched. + // .option('--max-duration ', 'Maximum duration in days (1-90)', parseInt) + .option('--enable', 'Enable the AB test on creation') + .option('--json', 'Output as JSON'); + + // Hide mode-specific and deprecated flags from the default options list. + // They are shown in the grouped help text below instead. + const hiddenFromDefaultHelp = new Set([ + '--runtime', + '--control-bundle', + '--control-version', + '--treatment-bundle', + '--treatment-version', + '--online-eval', + '--traffic-header', + '--control-endpoint', + '--treatment-endpoint', + '--control-online-eval', + '--treatment-online-eval', + '--gateway-filter', + '--control-qualifier', + '--treatment-qualifier', + ]); + for (const opt of abTestCmd.options) { + if (hiddenFromDefaultHelp.has(opt.long ?? '')) { + opt.hidden = true; + } + } + + // Add grouped help text after the default options section + abTestCmd.addHelpText( + 'after', + ` +Config-Bundle Mode (--mode config-bundle) -- default + Split traffic between two config bundle versions. + --runtime Runtime agent to A/B test + --control-bundle Control config bundle name or ARN + --control-version Control config bundle version + --treatment-bundle Treatment config bundle name or ARN + --treatment-version Treatment config bundle version + --online-eval Online evaluation config name or ARN + --traffic-header Header name for traffic routing + +Target-Based Mode (--mode target-based) + Route traffic to different runtime endpoints. + --control-endpoint Endpoint for control target + --treatment-endpoint Endpoint for treatment target + --control-online-eval Eval config name or ARN for control + --treatment-online-eval Eval config name or ARN for treatment + --gateway-filter Path pattern for routing +` + ); + + abTestCmd.action( + async (cliOptions: { + mode?: string; + name?: string; + description?: string; + runtime?: string; + gateway?: string; + roleArn?: string; + controlBundle?: string; + controlVersion?: string; + treatmentBundle?: string; + treatmentVersion?: string; + controlEndpoint?: string; + controlQualifier?: string; // deprecated alias for --control-endpoint + treatmentEndpoint?: string; + treatmentQualifier?: string; // deprecated alias for --treatment-endpoint + controlWeight?: number; + treatmentWeight?: number; + onlineEval?: string; + controlOnlineEval?: string; + treatmentOnlineEval?: string; + gatewayFilter?: string; + trafficHeader?: string; + maxDuration?: number; + enable?: boolean; + json?: boolean; + }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + // Resolve deprecated aliases (--control-qualifier -> --control-endpoint, etc.) + const resolvedControlEndpoint = cliOptions.controlEndpoint ?? cliOptions.controlQualifier; + const resolvedTreatmentEndpoint = cliOptions.treatmentEndpoint ?? cliOptions.treatmentQualifier; + + if (cliOptions.name || cliOptions.json) { + const fail = (error: string) => { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + }; + + const mode = cliOptions.mode ?? 'config-bundle'; + if (mode !== 'config-bundle' && mode !== 'target-based') { + fail(`Invalid --mode "${mode}". Must be one of: config-bundle, target-based`); + } + + if (!cliOptions.name) fail('--name is required'); + + // Target-based mode + if (mode === 'target-based') { + // Cross-validation: reject config-bundle flags + if (cliOptions.controlBundle) fail('--control-bundle cannot be used with --mode target-based'); + if (cliOptions.treatmentBundle) fail('--treatment-bundle cannot be used with --mode target-based'); + if (cliOptions.controlVersion) fail('--control-version cannot be used with --mode target-based'); + if (cliOptions.treatmentVersion) fail('--treatment-version cannot be used with --mode target-based'); + if (cliOptions.onlineEval) fail('--online-eval cannot be used with --mode target-based'); + + // Required flags + if (!cliOptions.gateway) fail('--gateway is required for target-based mode'); + if (!cliOptions.runtime) fail('--runtime is required for target-based mode'); + if (!resolvedControlEndpoint) fail('--control-endpoint is required for target-based mode'); + if (!resolvedTreatmentEndpoint) fail('--treatment-endpoint is required for target-based mode'); + if (cliOptions.controlWeight === undefined) fail('--control-weight is required'); + if (cliOptions.treatmentWeight === undefined) fail('--treatment-weight is required'); + + // Eval: require both online eval config names + if (!cliOptions.controlOnlineEval || !cliOptions.treatmentOnlineEval) { + fail( + '--control-online-eval and --treatment-online-eval are required. Create eval configs first with: agentcore add online-eval --endpoint ' + ); + } + + const result = await this.addTargetBased({ + name: cliOptions.name!, + description: cliOptions.description, + gateway: cliOptions.gateway!, + runtime: cliOptions.runtime!, + roleArn: cliOptions.roleArn, + controlEndpoint: resolvedControlEndpoint!, + treatmentEndpoint: resolvedTreatmentEndpoint!, + controlWeight: cliOptions.controlWeight!, + treatmentWeight: cliOptions.treatmentWeight!, + controlOnlineEval: cliOptions.controlOnlineEval!, + treatmentOnlineEval: cliOptions.treatmentOnlineEval!, + gatewayFilter: cliOptions.gatewayFilter, + enableOnCreate: cliOptions.enable, + }); + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added target-based AB test '${result.abTestName}'`); + } else { + console.error(result.error); + } + process.exit(result.success ? 0 : 1); + return; + } + + // Config-bundle mode (default) + // Cross-validation: reject target-based flags + if (cliOptions.gatewayFilter) fail('--gateway-filter requires --mode target-based'); + if (cliOptions.controlOnlineEval) fail('--control-online-eval requires --mode target-based'); + if (cliOptions.treatmentOnlineEval) fail('--treatment-online-eval requires --mode target-based'); + + if (!cliOptions.gateway && !cliOptions.runtime) + fail('--runtime is required (unless --gateway is provided)'); + if (!cliOptions.controlBundle) fail('--control-bundle is required'); + if (!cliOptions.controlVersion) fail('--control-version is required'); + if (!cliOptions.treatmentBundle) fail('--treatment-bundle is required'); + if (!cliOptions.treatmentVersion) fail('--treatment-version is required'); + if (cliOptions.controlWeight === undefined) fail('--control-weight is required'); + if (cliOptions.treatmentWeight === undefined) fail('--treatment-weight is required'); + if (!cliOptions.onlineEval) fail('--online-eval is required'); + + const result = await this.add({ + name: cliOptions.name!, + description: cliOptions.description, + agent: cliOptions.runtime ?? '', + gatewayChoice: cliOptions.gateway + ? { type: 'existing-http', name: cliOptions.gateway } + : { type: 'create-new' }, + roleArn: cliOptions.roleArn!, + controlBundle: cliOptions.controlBundle!, + controlVersion: cliOptions.controlVersion!, + treatmentBundle: cliOptions.treatmentBundle!, + treatmentVersion: cliOptions.treatmentVersion!, + controlWeight: cliOptions.controlWeight!, + treatmentWeight: cliOptions.treatmentWeight!, + onlineEval: cliOptions.onlineEval!, + trafficHeaderName: cliOptions.trafficHeader, + maxDurationDays: cliOptions.maxDuration, + enableOnCreate: cliOptions.enable, + }); + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added AB test '${result.abTestName}'`); + } else { + console.error(result.error); + } + process.exit(result.success ? 0 : 1); + } else { + // TUI fallback + const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/add/AddFlow'), + ]); + const { clear, unmount } = render( + React.createElement(AddFlow, { + isInteractive: false, + initialResource: 'ab-test', + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(getErrorMessage(error)); + } + process.exit(1); + } + } + ); + + removeCmd + .command(this.kind) + .description(`Remove ${this.article} ${this.label.toLowerCase()} from the project`) + .option('--name ', 'Name of resource to remove [non-interactive]') + .option('-y, --yes', 'Skip confirmation prompt [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .option('--delete-gateway', 'Also remove gateway targets and orphaned gateways (default: false)') + .action(async (cliOptions: { name?: string; yes?: boolean; json?: boolean; deleteGateway?: boolean }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.name || cliOptions.yes || cliOptions.json) { + if (!cliOptions.name) { + console.log(JSON.stringify({ success: false, error: '--name is required' })); + process.exit(1); + } + + const result = await this.remove(cliOptions.name, { deleteGateway: cliOptions.deleteGateway }); + console.log( + JSON.stringify({ + success: result.success, + resourceType: this.kind, + resourceName: cliOptions.name, + message: result.success ? `Removed ${this.label.toLowerCase()} '${cliOptions.name}'` : undefined, + error: !result.success ? result.error : undefined, + }) + ); + process.exit(result.success ? 0 : 1); + } else { + // TUI fallback + requireTTY(); + const [{ render }, { default: React }, { RemoveFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/remove'), + ]); + const { clear, unmount } = render( + React.createElement(RemoveFlow, { + isInteractive: false, + force: cliOptions.yes, + initialResourceType: this.kind, + initialResourceName: cliOptions.name, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + }); + } + + addScreen(): AddScreenComponent { + return null; + } + + private async createABTest(options: AddABTestOptions): Promise { + const project = await this.readProjectSpec(); + + this.checkDuplicate(project.abTests ?? [], options.name); + + // Resolve gateway reference based on the user's choice + let gatewayRef: string; + const choice = options.gatewayChoice ?? { type: 'create-new' }; + + if (choice.type === 'existing-http') { + // Reuse an existing HTTP gateway from the project spec + const existing = (project.httpGateways ?? []).find(gw => gw.name === choice.name); + if (!existing) { + throw new Error(`HTTP gateway "${choice.name}" not found in project.`); + } + gatewayRef = `{{gateway:${choice.name}}}`; + } else { + // Create new HTTP gateway — truncate name to fit 48-char limit + const httpGwName = `${options.name.replace(/_/g, '-').slice(0, 44)}-gw`; + const existingGw = (project.httpGateways ?? []).find(gw => gw.name === httpGwName); + if (existingGw) { + if (existingGw.runtimeRef !== options.agent) { + throw new Error( + `HTTP gateway "${httpGwName}" already exists with a different runtime (${existingGw.runtimeRef}). ` + + `Choose a different AB test name to avoid a gateway name collision.` + ); + } + } else { + project.httpGateways ??= []; + project.httpGateways.push({ + name: httpGwName, + runtimeRef: options.agent, + }); + } + gatewayRef = `{{gateway:${httpGwName}}}`; + } + + const abTest: ABTest = { + name: options.name, + mode: 'config-bundle', + ...(options.description && { description: options.description }), + gatewayRef, + ...(options.roleArn && { roleArn: options.roleArn }), + variants: [ + { + name: 'C', + weight: options.controlWeight, + variantConfiguration: { + configurationBundle: { + bundleArn: options.controlBundle, + bundleVersion: options.controlVersion, + }, + }, + }, + { + name: 'T1', + weight: options.treatmentWeight, + variantConfiguration: { + configurationBundle: { + bundleArn: options.treatmentBundle, + bundleVersion: options.treatmentVersion, + }, + }, + }, + ], + evaluationConfig: { + onlineEvaluationConfigArn: options.onlineEval, + }, + ...(options.trafficHeaderName && { + trafficAllocationConfig: { routeOnHeader: { headerName: options.trafficHeaderName } }, + }), + ...(options.maxDurationDays !== undefined && { maxDurationDays: options.maxDurationDays }), + ...(options.enableOnCreate !== undefined && { enableOnCreate: options.enableOnCreate }), + }; + + project.abTests ??= []; + project.abTests.push(abTest); + await this.writeProjectSpec(project); + + return abTest; + } + + async addTargetBased(options: AddTargetBasedABTestOptions): Promise> { + try { + const abTest = await this.createTargetBasedABTest(options); + return { success: true, abTestName: abTest.name }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + private async createTargetBasedABTest(options: AddTargetBasedABTestOptions): Promise { + const project = await this.readProjectSpec(); + + this.checkDuplicate(project.abTests ?? [], options.name); + + // Validate runtime exists + const runtime = project.runtimes.find(r => r.name === options.runtime); + if (!runtime) { + throw new Error(`Runtime "${options.runtime}" not found in project.`); + } + + // Validate endpoints exist on the runtime + if (!runtime.endpoints?.[options.controlEndpoint]) { + throw new Error( + `Endpoint "${options.controlEndpoint}" not found on runtime "${options.runtime}". Add it with: agentcore add runtime-endpoint` + ); + } + if (!runtime.endpoints?.[options.treatmentEndpoint]) { + throw new Error( + `Endpoint "${options.treatmentEndpoint}" not found on runtime "${options.runtime}". Add it with: agentcore add runtime-endpoint` + ); + } + + // Auto-generate target names from runtime + qualifier + const controlTarget = `${options.runtime}-${options.controlEndpoint}`; + const treatmentTarget = `${options.runtime}-${options.treatmentEndpoint}`; + + // Auto-create HTTP gateway if it doesn't exist + let existing = (project.httpGateways ?? []).find(gw => gw.name === options.gateway); + if (!existing) { + existing = { + name: options.gateway, + description: `HTTP gateway for AB test ${options.name}`, + runtimeRef: options.runtime, + targets: [ + { name: controlTarget, runtimeRef: options.runtime, qualifier: options.controlEndpoint }, + { name: treatmentTarget, runtimeRef: options.runtime, qualifier: options.treatmentEndpoint }, + ], + }; + project.httpGateways ??= []; + project.httpGateways.push(existing); + } else { + // Gateway exists — ensure targets exist + existing.targets ??= []; + if (!existing.targets.find(t => t.name === controlTarget)) { + existing.targets.push({ + name: controlTarget, + runtimeRef: options.runtime, + qualifier: options.controlEndpoint, + }); + } + if (!existing.targets.find(t => t.name === treatmentTarget)) { + existing.targets.push({ + name: treatmentTarget, + runtimeRef: options.runtime, + qualifier: options.treatmentEndpoint, + }); + } + } + const gatewayRef = `{{gateway:${options.gateway}}}`; + + // Look up online eval configs by name + const controlEvalConfig = project.onlineEvalConfigs.find(c => c.name === options.controlOnlineEval); + if (!controlEvalConfig) { + throw new Error( + `Online eval config '${options.controlOnlineEval}' not found. Create it first with: agentcore add online-eval` + ); + } + const treatmentEvalConfig = project.onlineEvalConfigs.find(c => c.name === options.treatmentOnlineEval); + if (!treatmentEvalConfig) { + throw new Error( + `Online eval config '${options.treatmentOnlineEval}' not found. Create it first with: agentcore add online-eval` + ); + } + + // Store eval names — post-deploy resolveOnlineEvalArn will resolve names to ARNs + const evaluationConfig: ABTest['evaluationConfig'] = { + perVariantOnlineEvaluationConfig: [ + { treatmentName: 'C' as const, onlineEvaluationConfigArn: options.controlOnlineEval }, + { treatmentName: 'T1' as const, onlineEvaluationConfigArn: options.treatmentOnlineEval }, + ], + }; + + const abTest: ABTest = { + name: options.name, + mode: 'target-based', + ...(options.description && { description: options.description }), + gatewayRef, + ...(options.roleArn && { roleArn: options.roleArn }), + variants: [ + { + name: 'C' as const, + weight: options.controlWeight, + variantConfiguration: { + target: { targetName: controlTarget }, + }, + }, + { + name: 'T1' as const, + weight: options.treatmentWeight, + variantConfiguration: { + target: { targetName: treatmentTarget }, + }, + }, + ], + evaluationConfig, + ...(options.gatewayFilter && { + gatewayFilter: { targetPaths: [options.gatewayFilter] }, + }), + ...(options.enableOnCreate !== undefined && { enableOnCreate: options.enableOnCreate }), + }; + + project.abTests ??= []; + project.abTests.push(abTest); + await this.writeProjectSpec(project); + + return abTest; + } +} diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index 4702633ed..b9873990b 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -25,6 +25,7 @@ import { parseAndNormalizeHeaders } from '../commands/shared/header-utils'; import type { VpcOptions } from '../commands/shared/vpc-utils'; import { VPC_ENDPOINT_WARNING, parseCommaSeparatedList } from '../commands/shared/vpc-utils'; import { getErrorMessage } from '../errors'; +import { createConfigBundleForAgent } from '../operations/agent/config-bundle-defaults'; import { mapGenerateConfigToRenderConfig, mapModelProviderToCredentials, @@ -34,6 +35,19 @@ import { import { executeImportAgent } from '../operations/agent/import'; import { setupPythonProject } from '../operations/python'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { + AgentType, + AuthorizerType, + Build, + Framework, + Language, + Memory, + ModelProvider as ModelProviderEnum, + NetworkMode as NetworkModeEnum, + Protocol, + standardize, +} from '../telemetry/schemas/common-shapes.js'; import { createRenderer } from '../templates'; import { requireTTY } from '../tui/guards/tty'; import type { GenerateConfig, MemoryOption } from '../tui/screens/generate/types'; @@ -76,6 +90,7 @@ export interface AddAgentOptions extends VpcOptions { idleTimeout?: number; maxLifetime?: number; sessionStorageMountPath?: string; + withConfigBundle?: boolean; } /** @@ -253,6 +268,10 @@ export class AgentPrimitive extends BasePrimitive', 'Absolute mount path for session filesystem storage (e.g. /mnt/session-storage) [non-interactive]' ) + .option( + '--with-config-bundle', + 'Create a config bundle wired into the agent template [preview] [non-interactive]' + ) .option('--json', 'Output as JSON [non-interactive]') .action(async options => { if (!findConfigRoot()) { @@ -264,92 +283,107 @@ export class AgentPrimitive extends BasePrimitive { + const validation = validateAddAgentOptions(cliOptions); + if (!validation.valid) { + throw new Error(validation.error); } - process.exit(1); - } - - // Parse custom claims JSON if provided (already validated by validateAddAgentOptions) - const customClaims = cliOptions.customClaims - ? (JSON.parse(cliOptions.customClaims) as CustomClaimValidation[]) - : undefined; - - // Parse request header allowlist if provided - const requestHeaderAllowlist = cliOptions.requestHeaderAllowlist - ? parseAndNormalizeHeaders(cliOptions.requestHeaderAllowlist) - : undefined; - - const result = await this.add({ - name: cliOptions.name!, - type: cliOptions.type ?? 'create', - buildType: (cliOptions.build as BuildType) ?? 'CodeZip', - language: cliOptions.language!, - framework: cliOptions.framework!, - modelProvider: cliOptions.modelProvider!, - apiKey: cliOptions.apiKey, - memory: cliOptions.memory, - protocol: cliOptions.protocol, - networkMode: cliOptions.networkMode, - subnets: cliOptions.subnets, - securityGroups: cliOptions.securityGroups, - requestHeaderAllowlist, - codeLocation: cliOptions.codeLocation, - entrypoint: cliOptions.entrypoint, - bedrockAgentId: cliOptions.agentId, - bedrockAliasId: cliOptions.agentAliasId, - bedrockRegion: cliOptions.region, - authorizerType: cliOptions.authorizerType, - discoveryUrl: cliOptions.discoveryUrl, - allowedAudience: cliOptions.allowedAudience, - allowedClients: cliOptions.allowedClients, - allowedScopes: cliOptions.allowedScopes, - customClaims, - clientId: cliOptions.clientId, - clientSecret: cliOptions.clientSecret, - idleTimeout: cliOptions.idleTimeout ? Number(cliOptions.idleTimeout) : undefined, - maxLifetime: cliOptions.maxLifetime ? Number(cliOptions.maxLifetime) : undefined, - sessionStorageMountPath: cliOptions.sessionStorageMountPath, - }); - if (cliOptions.json) { - console.log(JSON.stringify(result)); - } else if (result.success) { - console.log(`Added agent '${result.agentName}'`); - if (result.agentPath) { - console.log(`Agent code: ${result.agentPath}`); + // Parse custom claims JSON if provided (already validated by validateAddAgentOptions) + const customClaims = cliOptions.customClaims + ? (JSON.parse(cliOptions.customClaims) as CustomClaimValidation[]) + : undefined; + + // Parse request header allowlist if provided + const requestHeaderAllowlist = cliOptions.requestHeaderAllowlist + ? parseAndNormalizeHeaders(cliOptions.requestHeaderAllowlist) + : undefined; + + const result = await this.add({ + name: cliOptions.name!, + type: cliOptions.type ?? 'create', + buildType: (cliOptions.build as BuildType) ?? 'CodeZip', + language: cliOptions.language!, + framework: cliOptions.framework!, + modelProvider: cliOptions.modelProvider!, + apiKey: cliOptions.apiKey, + memory: cliOptions.memory, + protocol: cliOptions.protocol, + networkMode: cliOptions.networkMode, + subnets: cliOptions.subnets, + securityGroups: cliOptions.securityGroups, + requestHeaderAllowlist, + codeLocation: cliOptions.codeLocation, + entrypoint: cliOptions.entrypoint, + bedrockAgentId: cliOptions.agentId, + bedrockAliasId: cliOptions.agentAliasId, + bedrockRegion: cliOptions.region, + authorizerType: cliOptions.authorizerType, + discoveryUrl: cliOptions.discoveryUrl, + allowedAudience: cliOptions.allowedAudience, + allowedClients: cliOptions.allowedClients, + allowedScopes: cliOptions.allowedScopes, + customClaims, + clientId: cliOptions.clientId, + clientSecret: cliOptions.clientSecret, + idleTimeout: cliOptions.idleTimeout ? Number(cliOptions.idleTimeout) : undefined, + maxLifetime: cliOptions.maxLifetime ? Number(cliOptions.maxLifetime) : undefined, + sessionStorageMountPath: cliOptions.sessionStorageMountPath, + withConfigBundle: cliOptions.withConfigBundle, + }); + + if (!result.success) { + throw new Error(result.error); } - if (cliOptions.networkMode === 'VPC') { - console.log(`\x1b[33mNote: ${VPC_ENDPOINT_WARNING}\x1b[0m`); + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else { + console.log(`Added agent '${result.agentName}'`); + if (result.agentPath) { + console.log(`Agent code: ${result.agentPath}`); + } + if (cliOptions.networkMode === 'VPC') { + console.log(`\x1b[33mNote: ${VPC_ENDPOINT_WARNING}\x1b[0m`); + } } - } else { - console.error(result.error); - } - process.exit(result.success ? 0 : 1); + return { + language: standardize(Language, cliOptions.language), + framework: standardize(Framework, cliOptions.framework), + model_provider: standardize(ModelProviderEnum, cliOptions.modelProvider), + agent_type: standardize(AgentType, cliOptions.type ?? 'create'), + build: standardize(Build, cliOptions.build ?? 'CodeZip'), + protocol: standardize(Protocol, cliOptions.protocol ?? 'HTTP'), + network_mode: standardize(NetworkModeEnum, cliOptions.networkMode ?? 'PUBLIC'), + authorizer_type: standardize(AuthorizerType, cliOptions.authorizerType ?? 'NONE'), + memory: standardize(Memory, cliOptions.memory ?? 'none'), + }; + }); } else { - // TUI fallback — dynamic imports to avoid pulling ink (async) into registry - requireTTY(); - const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ - import('ink'), - import('react'), - import('../tui/screens/add/AddFlow'), - ]); - const { clear, unmount } = render( - React.createElement(AddFlow, { - isInteractive: false, - initialResource: 'agent', - onExit: () => { - clear(); - unmount(); - process.exit(0); - }, - }) - ); + try { + // TUI fallback — dynamic imports to avoid pulling ink (async) into registry + requireTTY(); + const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/add/AddFlow'), + ]); + const { clear, unmount } = render( + React.createElement(AddFlow, { + isInteractive: false, + initialResource: 'agent', + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } catch (error) { + console.error(getErrorMessage(error)); + process.exit(1); + } } }); @@ -412,6 +446,7 @@ export class AgentPrimitive extends BasePrimitive }>; + branchName?: string; + commitMessage?: string; +} + +export type RemovableConfigBundle = RemovableResource; + +/** + * ConfigBundlePrimitive handles all configuration bundle add/remove operations. + * + * Configuration bundles are versioned collections of component configurations + * (system prompts, tool configs) keyed by component ARN. They are created via + * direct API calls (not CloudFormation) and stored in agentcore.json for + * lifecycle management. + */ +export class ConfigBundlePrimitive extends BasePrimitive { + readonly kind = 'config-bundle' as const; + readonly label = 'Configuration Bundle'; + override readonly article = 'a'; + readonly primitiveSchema = ConfigBundleSchema; + + async add(options: AddConfigBundleOptions): Promise> { + try { + const bundle = await this.createConfigBundle(options); + return { success: true, bundleName: bundle.name }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + async remove(bundleName: string): Promise { + try { + const project = await this.readProjectSpec(); + + const index = (project.configBundles ?? []).findIndex(b => b.name === bundleName); + if (index === -1) { + return { success: false, error: `Configuration bundle "${bundleName}" not found.` }; + } + + project.configBundles.splice(index, 1); + await this.writeProjectSpec(project); + + return { success: true }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + async previewRemove(bundleName: string): Promise { + const project = await this.readProjectSpec(); + + const bundle = (project.configBundles ?? []).find(b => b.name === bundleName); + if (!bundle) { + throw new Error(`Configuration bundle "${bundleName}" not found.`); + } + + const summary: string[] = [`Removing configuration bundle: ${bundleName}`]; + const schemaChanges: SchemaChange[] = []; + + const afterSpec = { + ...project, + configBundles: (project.configBundles ?? []).filter(b => b.name !== bundleName), + }; + + schemaChanges.push({ + file: 'agentcore/agentcore.json', + before: project, + after: afterSpec, + }); + + return { summary, directoriesToDelete: [], schemaChanges }; + } + + async getRemovable(): Promise { + try { + const project = await this.readProjectSpec(); + return (project.configBundles ?? []).map(b => ({ name: b.name })); + } catch { + return []; + } + } + + async getAllNames(): Promise { + try { + const project = await this.readProjectSpec(); + return (project.configBundles ?? []).map(b => b.name); + } catch { + return []; + } + } + + registerCommands(addCmd: Command, removeCmd: Command): void { + addCmd + .command(this.kind) + .description('[preview] Add a configuration bundle to the project') + .option('--name ', 'Bundle name') + .option('--description ', 'Bundle description') + .option( + '--components ', + 'Components map as inline JSON. Keys are ARNs or placeholders: {{runtime:}}, {{gateway:}}. Placeholders resolve to real ARNs at deploy time.' + ) + .option('--components-file ', 'Path to components JSON file (same format as --components)') + .option('--branch ', 'Branch name for versioning') + .option('--commit-message ', 'Commit message for this version') + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + name?: string; + description?: string; + components?: string; + componentsFile?: string; + branch?: string; + commitMessage?: string; + json?: boolean; + }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.name || cliOptions.json) { + const fail = (error: string) => { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + }; + + if (!cliOptions.name) { + fail('--name is required in non-interactive mode'); + } + + if (!cliOptions.components && !cliOptions.componentsFile) { + fail('Either --components or --components-file is required'); + } + + let components: Record }>; + if (cliOptions.componentsFile) { + const raw = readFileSync(cliOptions.componentsFile, 'utf-8'); + components = JSON.parse(raw) as Record }>; + } else { + components = JSON.parse(cliOptions.components!) as Record< + string, + { configuration: Record } + >; + } + + const result = await this.add({ + name: cliOptions.name!, + description: cliOptions.description, + components, + branchName: cliOptions.branch, + commitMessage: cliOptions.commitMessage, + }); + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added configuration bundle '${result.bundleName}'`); + } else { + console.error(result.error); + } + process.exit(result.success ? 0 : 1); + } else { + // TUI fallback + const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/add/AddFlow'), + ]); + const { clear, unmount } = render( + React.createElement(AddFlow, { + isInteractive: false, + initialResource: 'config-bundle', + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(getErrorMessage(error)); + } + process.exit(1); + } + } + ); + + this.registerRemoveSubcommand(removeCmd); + } + + addScreen(): AddScreenComponent { + return null; + } + + private async createConfigBundle(options: AddConfigBundleOptions): Promise { + const project = await this.readProjectSpec(); + + this.checkDuplicate(project.configBundles ?? [], options.name); + + const bundle: ConfigBundle = { + name: options.name, + type: 'ConfigurationBundle', + ...(options.description && { description: options.description }), + components: options.components, + branchName: options.branchName ?? 'mainline', + ...(options.commitMessage && { commitMessage: options.commitMessage }), + }; + + project.configBundles ??= []; + project.configBundles.push(bundle); + await this.writeProjectSpec(project); + + return bundle; + } +} diff --git a/src/cli/primitives/CredentialPrimitive.tsx b/src/cli/primitives/CredentialPrimitive.tsx index 52f578235..9607094f8 100644 --- a/src/cli/primitives/CredentialPrimitive.tsx +++ b/src/cli/primitives/CredentialPrimitive.tsx @@ -4,6 +4,8 @@ import { CredentialSchema } from '../../schema'; import { validateAddCredentialOptions } from '../commands/add/validate'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { CredentialType, standardize } from '../telemetry/schemas/common-shapes.js'; import { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; import { computeDefaultCredentialEnvVarName } from './credential-utils'; @@ -273,23 +275,23 @@ export class CredentialPrimitive extends BasePrimitive { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } - if ( - cliOptions.name || - cliOptions.apiKey || - cliOptions.json || - cliOptions.type || - cliOptions.discoveryUrl || - cliOptions.clientId || - cliOptions.clientSecret || - cliOptions.scopes - ) { - // CLI mode + if ( + cliOptions.name || + cliOptions.apiKey || + cliOptions.json || + cliOptions.type || + cliOptions.discoveryUrl || + cliOptions.clientId || + cliOptions.clientSecret || + cliOptions.scopes + ) { + // CLI mode + await cliCommandRun('add.credential', !!cliOptions.json, async () => { const validation = validateAddCredentialOptions({ name: cliOptions.name, type: cliOptions.type as 'api-key' | 'oauth' | undefined, @@ -301,12 +303,7 @@ export class CredentialPrimitive extends BasePrimitive { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } - if (cliOptions.name || cliOptions.json) { - const fail = (error: string) => { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error })); - } else { - console.error(error); - } - process.exit(1); + if (cliOptions.name || cliOptions.json) { + await cliCommandRun('add.evaluator', !!cliOptions.json, async () => { + const fail = (error: string): never => { + throw new Error(error); }; if (!cliOptions.name || !cliOptions.level) { @@ -298,9 +295,13 @@ export class EvaluatorPrimitive extends BasePrimitive) => { const cliOptions = rawOptions as unknown as CLIAddGatewayOptions; - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } - + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + await cliCommandRun('add.gateway', !!cliOptions.json, async () => { const validation = validateAddGatewayOptions(cliOptions); if (!validation.valid) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: validation.error })); - } else { - console.error(validation.error); - } - process.exit(1); + throw new Error(validation.error); } // Parse custom claims JSON if provided (already validated) @@ -221,23 +217,30 @@ export class GatewayPrimitive extends BasePrimitive s.trim()) + .filter(Boolean).length + : 0; + return { + authorizer_type: standardize(AuthorizerType, cliOptions.authorizerType ?? 'NONE'), + has_policy_engine: !!cliOptions.policyEngine, + policy_engine_mode: standardize(PolicyEngineMode, cliOptions.policyEngineMode ?? 'log_only'), + semantic_search: cliOptions.semanticSearch !== false, + runtime_count: runtimeCount, + }; + }); }); removeCmd diff --git a/src/cli/primitives/GatewayTargetPrimitive.ts b/src/cli/primitives/GatewayTargetPrimitive.ts index 41a2e6a75..e8a1da996 100644 --- a/src/cli/primitives/GatewayTargetPrimitive.ts +++ b/src/cli/primitives/GatewayTargetPrimitive.ts @@ -14,6 +14,13 @@ import { validateAddGatewayTargetOptions } from '../commands/add/validate'; import { getErrorMessage } from '../errors'; import type { RemovableGatewayTarget } from '../operations/remove/remove-gateway-target'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { + GATEWAY_TARGET_TYPE_MAP, + GatewayTargetHost, + OutboundAuth, + standardize, +} from '../telemetry/schemas/common-shapes.js'; import { getTemplateToolDefinitions, renderGatewayTargetTemplate } from '../templates/GatewayTargetRenderer'; import { requireTTY } from '../tui/guards/tty'; import type { @@ -297,20 +304,15 @@ export class GatewayTargetPrimitive extends BasePrimitive { const validation = await validateAddGatewayTargetOptions(cliOptions); if (!validation.valid) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: validation.error })); - } else { - console.error(validation.error); - } - process.exit(1); + throw new Error(validation.error); } // Map CLI flag values to internal types @@ -321,6 +323,19 @@ export class GatewayTargetPrimitive extends BasePrimitive { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } - if (cliOptions.name || cliOptions.json) { - // CLI mode + if (cliOptions.name || cliOptions.json) { + // CLI mode + await cliCommandRun('add.memory', !!cliOptions.json, async () => { const expiry = cliOptions.expiry ? parseInt(cliOptions.expiry, 10) : undefined; const validation = validateAddMemoryOptions({ name: cliOptions.name, @@ -203,12 +204,7 @@ export class MemoryPrimitive extends BasePrimitive s.trim().toUpperCase()) + .filter(Boolean); + return { + strategy_count: strategyList.length, + strategy_semantic: strategyList.includes('SEMANTIC'), + strategy_summarization: strategyList.includes('SUMMARIZATION'), + strategy_user_preference: strategyList.includes('USER_PREFERENCE'), + strategy_episodic: strategyList.includes('EPISODIC'), + }; + }); + } else { + try { // TUI fallback — dynamic imports to avoid pulling ink (async) into registry requireTTY(); const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ @@ -248,14 +259,10 @@ export class MemoryPrimitive extends BasePrimitive', 'Evaluator name(s), Builtin.* IDs, or ARNs [non-interactive]') .option('--evaluator-arn ', 'Evaluator ARN(s) [non-interactive]') .option('--sampling-rate ', 'Sampling percentage (0.01-100) [non-interactive]') + .option('--endpoint ', 'Runtime endpoint name to scope monitoring [non-interactive]') .option('--enable-on-create', 'Enable evaluation immediately after deploy [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') .action( @@ -118,40 +121,32 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } - if (cliOptions.name || cliOptions.json) { - // Merge --evaluator and --evaluator-arn into a single list - const allEvaluators = [...(cliOptions.evaluator ?? []), ...(cliOptions.evaluatorArn ?? [])]; + if (cliOptions.name || cliOptions.json) { + // Merge --evaluator and --evaluator-arn into a single list + const allEvaluators = [...(cliOptions.evaluator ?? []), ...(cliOptions.evaluatorArn ?? [])]; + await cliCommandRun('add.online-eval', !!cliOptions.json, async () => { if (!cliOptions.name || !cliOptions.runtime || allEvaluators.length === 0 || !cliOptions.samplingRate) { - const error = - '--name, --runtime, --evaluator (and/or --evaluator-arn), and --sampling-rate are all required in non-interactive mode'; - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error })); - } else { - console.error(error); - } - process.exit(1); + throw new Error( + '--name, --runtime, --evaluator (and/or --evaluator-arn), and --sampling-rate are all required in non-interactive mode' + ); } // Sampling rate as a percentage of requests to evaluate (0.01% to 100%) const samplingRate = parseFloat(cliOptions.samplingRate); if (isNaN(samplingRate) || samplingRate < 0.01 || samplingRate > 100) { - const error = `Invalid --sampling-rate "${cliOptions.samplingRate}". Must be a percentage between 0.01 and 100`; - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error })); - } else { - console.error(error); - } - process.exit(1); + throw new Error( + `Invalid --sampling-rate "${cliOptions.samplingRate}". Must be a percentage between 0.01 and 100` + ); } const result = await this.add({ @@ -160,17 +155,26 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive r.name === options.agent); + if (!runtime) { + throw new Error(`Runtime "${options.agent}" not found in project.`); + } + if (!runtime.endpoints?.[options.endpoint]) { + throw new Error( + `Endpoint "${options.endpoint}" not found on runtime "${options.agent}". Available endpoints: ${ + runtime.endpoints ? Object.keys(runtime.endpoints).join(', ') : '(none)' + }` + ); + } + } + const config: OnlineEvalConfig = { name: options.name, agent: options.agent, evaluators: options.evaluators, samplingRate: options.samplingRate, ...(options.enableOnCreate !== undefined && { enableOnCreate: options.enableOnCreate }), + ...(options.endpoint && { endpoint: options.endpoint }), }; project.onlineEvalConfigs.push(config); diff --git a/src/cli/primitives/PolicyEnginePrimitive.ts b/src/cli/primitives/PolicyEnginePrimitive.ts index a1f887547..bb9b314d8 100644 --- a/src/cli/primitives/PolicyEnginePrimitive.ts +++ b/src/cli/primitives/PolicyEnginePrimitive.ts @@ -3,6 +3,8 @@ import type { AgentCoreProjectSpec, PolicyEngine } from '../../schema'; import { PolicyEngineModeSchema, PolicyEngineSchema } from '../../schema'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { AttachMode, standardize } from '../telemetry/schemas/common-shapes.js'; import { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; import { SOURCE_CODE_NOTE } from './constants'; @@ -221,20 +223,15 @@ export class PolicyEnginePrimitive extends BasePrimitive { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } - if (cliOptions.name || cliOptions.description || cliOptions.encryptionKeyArn || cliOptions.json) { + if (cliOptions.name || cliOptions.description || cliOptions.encryptionKeyArn || cliOptions.json) { + await cliCommandRun('add.policy-engine', !!cliOptions.json, async () => { if (!cliOptions.name) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: '--name is required' })); - } else { - console.error('--name is required'); - } - process.exit(1); + throw new Error('--name is required'); } const result = await this.add({ @@ -253,15 +250,29 @@ export class PolicyEnginePrimitive extends BasePrimitive s.trim()) + .filter(Boolean).length + : 0; + return { + attach_gateway_count: gatewayCount, + attach_mode: standardize(AttachMode, cliOptions.attachMode ?? 'log_only'), + }; + }); + } else { + try { requireTTY(); const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ import('ink'), @@ -278,14 +289,10 @@ export class PolicyEnginePrimitive extends BasePrimitive { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } - if ( - cliOptions.name || - cliOptions.engine || - cliOptions.source || - cliOptions.statement || - cliOptions.generate || - cliOptions.json - ) { + if ( + cliOptions.name || + cliOptions.engine || + cliOptions.source || + cliOptions.statement || + cliOptions.generate || + cliOptions.json + ) { + await cliCommandRun('add.policy', !!cliOptions.json, async () => { if (!cliOptions.name) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: '--name is required' })); - } else { - console.error('--name is required'); - } - process.exit(1); + throw new Error('--name is required'); } if (!cliOptions.engine) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: '--engine is required' })); - } else { - console.error('--engine is required'); - } - process.exit(1); + throw new Error('--engine is required'); } const result = await this.add({ @@ -335,15 +327,28 @@ export class PolicyPrimitive extends BasePrimitive { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + await cliCommandRun('add.runtime-endpoint', !!cliOptions.json, async () => { const result = await this.add({ runtime: cliOptions.runtime, endpoint: cliOptions.endpoint, @@ -261,23 +262,18 @@ export class RuntimeEndpointPrimitive extends BasePrimitive ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + }, + findConfigRoot: () => '/fake/root', +})); + +function makeProject(abTests: { name: string; gatewayRef?: string }[] = []) { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests, + httpGateways: [] as { name: string; runtimeRef: string }[], + }; +} + +const validOptions: AddABTestOptions = { + name: 'MyTest', + agent: 'my-agent', + controlBundle: 'arn:bundle:control', + controlVersion: 'v1', + treatmentBundle: 'arn:bundle:treatment', + treatmentVersion: 'v1', + controlWeight: 80, + treatmentWeight: 20, + onlineEval: 'arn:eval:config', +}; + +let primitive: ABTestPrimitive; + +describe('ABTestPrimitive', () => { + beforeEach(() => { + vi.clearAllMocks(); + primitive = new ABTestPrimitive(); + }); + + it('has correct kind, label, and article', () => { + expect(primitive.kind).toBe('ab-test'); + expect(primitive.label).toBe('AB Test'); + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(primitive['article']).toBe('an'); + }); + + describe('add', () => { + it('adds AB test to project spec and returns success', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.add(validOptions); + + expect(result.success).toBe(true); + expect(result).toHaveProperty('abTestName', 'MyTest'); + + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.abTests).toHaveLength(1); + expect(writtenSpec.abTests[0].name).toBe('MyTest'); + expect(writtenSpec.abTests[0].variants).toHaveLength(2); + expect(writtenSpec.abTests[0].variants[0].name).toBe('C'); + expect(writtenSpec.abTests[0].variants[0].weight).toBe(80); + expect(writtenSpec.abTests[0].variants[1].name).toBe('T1'); + expect(writtenSpec.abTests[0].variants[1].weight).toBe(20); + }); + + it('includes optional fields when provided', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + await primitive.add({ + ...validOptions, + description: 'Test description', + roleArn: 'arn:aws:iam::123:role/MyRole', + trafficHeaderName: 'X-AB-Route', + maxDurationDays: 30, + enableOnCreate: true, + }); + + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + const test = writtenSpec.abTests[0]; + expect(test.description).toBe('Test description'); + expect(test.roleArn).toBe('arn:aws:iam::123:role/MyRole'); + expect(test.trafficAllocationConfig).toEqual({ routeOnHeader: { headerName: 'X-AB-Route' } }); + expect(test.maxDurationDays).toBe(30); + expect(test.enableOnCreate).toBe(true); + }); + + it('omits optional fields when not provided', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + await primitive.add(validOptions); + + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + const test = writtenSpec.abTests[0]; + expect(test.description).toBeUndefined(); + expect(test.roleArn).toBeUndefined(); + expect(test.trafficAllocationConfig).toBeUndefined(); + expect(test.maxDurationDays).toBeUndefined(); + expect(test.enableOnCreate).toBeUndefined(); + }); + + it('returns error when AB test name already exists', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyTest' }])); + + const result = await primitive.add(validOptions); + + expect(result).toEqual( + expect.objectContaining({ success: false, error: expect.stringContaining('already exists') }) + ); + }); + + it('returns error when readProjectSpec fails', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('disk read error')); + + const result = await primitive.add(validOptions); + + expect(result).toEqual(expect.objectContaining({ success: false, error: 'disk read error' })); + }); + + it('returns error when writeProjectSpec fails', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockRejectedValue(new Error('disk write error')); + + const result = await primitive.add(validOptions); + + expect(result).toEqual(expect.objectContaining({ success: false, error: 'disk write error' })); + }); + + it('returns error when variant weights do not sum to 100', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + const result = await primitive.add({ + ...validOptions, + controlWeight: 80, + treatmentWeight: 80, + }); + + expect(result.success).toBe(false); + }); + }); + + describe('remove', () => { + it('removes AB test from project spec', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'TestA' }, { name: 'TestB' }])); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('TestA'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.abTests).toHaveLength(1); + expect(writtenSpec.abTests[0].name).toBe('TestB'); + }); + + it('returns error when AB test not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + const result = await primitive.remove('NonExistent'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('NonExistent'); + expect(result.error).toContain('not found'); + } + }); + + it('returns error when readProjectSpec fails', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('io error')); + + const result = await primitive.remove('Whatever'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBe('io error'); + } + }); + + it('cascade-deletes orphaned HTTP gateway when last referencing AB test is removed', async () => { + const project = makeProject([{ name: 'TestA', gatewayRef: '{{gateway:TestA-gw}}' }]); + project.httpGateways = [{ name: 'TestA-gw', runtimeRef: 'my-agent' }]; + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('TestA'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.abTests).toHaveLength(0); + // Gateway is retained by default — cascade-delete only happens with deleteGateway: true + expect(writtenSpec.httpGateways).toHaveLength(1); + }); + + it('retains HTTP gateway when another AB test still references it', async () => { + const project = makeProject([ + { name: 'TestA', gatewayRef: '{{gateway:shared-gw}}' }, + { name: 'TestB', gatewayRef: '{{gateway:shared-gw}}' }, + ]); + project.httpGateways = [{ name: 'shared-gw', runtimeRef: 'my-agent' }]; + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('TestA'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.abTests).toHaveLength(1); + expect(writtenSpec.httpGateways).toHaveLength(1); + expect(writtenSpec.httpGateways[0].name).toBe('shared-gw'); + }); + }); + + describe('previewRemove', () => { + it('returns preview with schema changes', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'TestA' }])); + + const preview = await primitive.previewRemove('TestA'); + + expect(preview.summary[0]).toContain('Removing AB test: TestA'); + expect(preview.schemaChanges).toHaveLength(1); + expect(preview.schemaChanges[0]!.file).toBe('agentcore/agentcore.json'); + expect((preview.schemaChanges[0]!.after as { abTests: unknown[] }).abTests).toHaveLength(0); + }); + + it('throws when AB test not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + await expect(primitive.previewRemove('Missing')).rejects.toThrow('not found'); + }); + }); + + describe('getRemovable', () => { + it('returns AB test names', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'A' }, { name: 'B' }])); + + const result = await primitive.getRemovable(); + + expect(result).toEqual([{ name: 'A' }, { name: 'B' }]); + }); + + it('returns empty array on error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('fail')); + + expect(await primitive.getRemovable()).toEqual([]); + }); + }); + + describe('getAllNames', () => { + it('returns AB test names as strings', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'X' }, { name: 'Y' }])); + + const result = await primitive.getAllNames(); + + expect(result).toEqual(['X', 'Y']); + }); + + it('returns empty array on error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('fail')); + + expect(await primitive.getAllNames()).toEqual([]); + }); + }); +}); diff --git a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts index 4c4c66402..5f136eabe 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -13,6 +13,9 @@ const defaultProject: AgentCoreProjectSpec = { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const { mockConfigExists, mockReadProjectSpec, mockWriteProjectSpec } = vi.hoisted(() => ({ diff --git a/src/cli/primitives/__tests__/auth-utils.test.ts b/src/cli/primitives/__tests__/auth-utils.test.ts index 3fce5148f..5f0e1a7c9 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -93,6 +93,9 @@ describe('createManagedOAuthCredential', () => { onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], }; const jwtConfig: JwtConfigOptions = { diff --git a/src/cli/primitives/index.ts b/src/cli/primitives/index.ts index 2f19f9f3a..3f69da1ed 100644 --- a/src/cli/primitives/index.ts +++ b/src/cli/primitives/index.ts @@ -1,3 +1,4 @@ +export { ABTestPrimitive } from './ABTestPrimitive'; export { BasePrimitive } from './BasePrimitive'; export { MemoryPrimitive } from './MemoryPrimitive'; export { CredentialPrimitive } from './CredentialPrimitive'; @@ -17,6 +18,8 @@ export { onlineEvalConfigPrimitive, gatewayPrimitive, gatewayTargetPrimitive, + configBundlePrimitive, + abTestPrimitive, runtimeEndpointPrimitive, getPrimitive, } from './registry'; diff --git a/src/cli/primitives/registry.ts b/src/cli/primitives/registry.ts index 2680d1ea6..754b4e182 100644 --- a/src/cli/primitives/registry.ts +++ b/src/cli/primitives/registry.ts @@ -1,5 +1,7 @@ +import { ABTestPrimitive } from './ABTestPrimitive'; import { AgentPrimitive } from './AgentPrimitive'; import type { BasePrimitive } from './BasePrimitive'; +import { ConfigBundlePrimitive } from './ConfigBundlePrimitive'; import { CredentialPrimitive } from './CredentialPrimitive'; import { EvaluatorPrimitive } from './EvaluatorPrimitive'; import { GatewayPrimitive } from './GatewayPrimitive'; @@ -23,6 +25,8 @@ export const gatewayPrimitive = new GatewayPrimitive(); export const gatewayTargetPrimitive = new GatewayTargetPrimitive(); export const policyEnginePrimitive = new PolicyEnginePrimitive(); export const policyPrimitive = new PolicyPrimitive(); +export const configBundlePrimitive = new ConfigBundlePrimitive(); +export const abTestPrimitive = new ABTestPrimitive(); export const runtimeEndpointPrimitive = new RuntimeEndpointPrimitive(); /** @@ -38,6 +42,8 @@ export const ALL_PRIMITIVES: BasePrimitive[] = [ gatewayTargetPrimitive, policyEnginePrimitive, policyPrimitive, + configBundlePrimitive, + abTestPrimitive, runtimeEndpointPrimitive, ]; diff --git a/src/cli/project.ts b/src/cli/project.ts index d588b8ca6..14ea7be3c 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -18,6 +18,9 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS onlineEvalConfigs: [], agentCoreGateways: [], policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], tags: { 'agentcore:created-by': 'agentcore-cli', 'agentcore:project-name': projectName, diff --git a/src/cli/telemetry/__tests__/client.test.ts b/src/cli/telemetry/__tests__/client.test.ts index e254524bf..96adebafc 100644 --- a/src/cli/telemetry/__tests__/client.test.ts +++ b/src/cli/telemetry/__tests__/client.test.ts @@ -116,18 +116,49 @@ describe('TelemetryClient', () => { expect(sink.metrics[0]!.attrs.check_only).toBe('true'); }); - it('silently drops invalid success payloads', async () => { + it('publishes metric with unknown defaults for incomplete success payloads', async () => { const sink = new InMemorySink(); const client = new TelemetryClient(sink); - // Missing required attrs for 'create' — should silently drop + // Missing required attrs for 'create' — should still publish with 'unknown' defaults await client.withCommandRun( 'create', // @ts-expect-error — intentionally incomplete async () => ({ language: 'python' }) ); - expect(sink.metrics).toHaveLength(0); + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs).toMatchObject({ + exit_reason: 'success', + language: 'python', + framework: 'unknown', + model_provider: 'unknown', + }); + }); + + it('defaults invalid attrs to unknown while preserving valid ones', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + await client.withCommandRun( + 'create', + // @ts-expect-error — intentionally invalid enum value + async () => ({ + language: 'rust', // invalid enum + framework: 'strands', + model_provider: 'bedrock', + memory: 'shortterm', + protocol: 'mcp', + build: 'codezip', + agent_type: 'create', + network_mode: 'public', + has_agent: true, + }) + ); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs.language).toBe('unknown'); + expect(sink.metrics[0]!.attrs.framework).toBe('strands'); }); it('records cancel when callback returns CANCELLED', async () => { diff --git a/src/cli/telemetry/__tests__/filesystem-sink.test.ts b/src/cli/telemetry/__tests__/filesystem-sink.test.ts new file mode 100644 index 000000000..50d8d4620 --- /dev/null +++ b/src/cli/telemetry/__tests__/filesystem-sink.test.ts @@ -0,0 +1,95 @@ +import { createTempConfig } from '../../__tests__/helpers/temp-config'; +import { resolveAuditFilePath } from '../config'; +import { FileSystemSink } from '../sinks/filesystem-sink'; +import { readFile } from 'fs/promises'; +import { join } from 'node:path'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; + +const tmp = createTempConfig('fs-sink'); +const outputDir = join(tmp.configDir, 'telemetry'); + +function createSink(opts: { dir?: string; log?: (msg: string) => void } = {}) { + const filePath = join(opts.dir ?? outputDir, 'test-session.json'); + return new FileSystemSink({ filePath, log: opts.log }); +} + +function readJsonl(path: string): Promise { + return readFile(path, 'utf-8').then(data => + data + .trim() + .split('\n') + .map(line => JSON.parse(line)) + ); +} + +describe('FileSystemSink', () => { + beforeEach(() => tmp.setup()); + afterAll(() => tmp.cleanup()); + + it('writes each record as a JSONL line on disk', async () => { + const sink = createSink(); + sink.record(42, { command_group: 'deploy', command: 'deploy', exit_reason: 'success' }); + await sink.flush(); + + const entries = await readJsonl(join(outputDir, 'test-session.json')); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + value: 42, + attrs: { command_group: 'deploy', command: 'deploy', exit_reason: 'success' }, + }); + }); + + it('appends multiple records as separate lines', async () => { + const sink = createSink(); + sink.record(10, { command_group: 'add', command: 'add.agent' }); + sink.record(20, { command_group: 'add', command: 'add.memory' }); + await sink.flush(); + + const entries = await readJsonl(join(outputDir, 'test-session.json')); + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ value: 10 }); + expect(entries[1]).toMatchObject({ value: 20 }); + }); + + it('creates output directory if it does not exist', async () => { + const nested = join(tmp.testDir, 'deep', 'nested', 'telemetry'); + const filePath = join(nested, 'test.json'); + const sink = new FileSystemSink({ filePath }); + sink.record(1, { command_group: 'status', command: 'status' }); + await sink.flush(); + + const entries = await readJsonl(filePath); + expect(entries).toHaveLength(1); + }); + + it('flush is a no-op when no records exist', async () => { + const sink = createSink(); + await expect(sink.flush()).resolves.toBeUndefined(); + }); + + it('shutdown logs audit message when records were written', async () => { + const logged: string[] = []; + const sink = createSink({ log: msg => logged.push(msg) }); + sink.record(99, { command_group: 'invoke', command: 'invoke' }); + await sink.shutdown(); + + expect(logged).toHaveLength(1); + expect(logged[0]).toContain('[audit mode]'); + expect(logged[0]).toContain('test-session.json'); + }); + + it('shutdown does not log when no records were written', async () => { + const logged: string[] = []; + const sink = createSink({ log: msg => logged.push(msg) }); + await sink.shutdown(); + + expect(logged).toHaveLength(0); + }); +}); + +describe('resolveAuditFilePath', () => { + it('joins outputDir, entrypoint, and sessionId into a JSON file path', () => { + const path = resolveAuditFilePath('/home/user/.agentcore/telemetry', 'deploy', 'abc-123'); + expect(path).toBe('/home/user/.agentcore/telemetry/deploy-abc-123.json'); + }); +}); diff --git a/src/cli/telemetry/cli-command-run.ts b/src/cli/telemetry/cli-command-run.ts new file mode 100644 index 000000000..987f05730 --- /dev/null +++ b/src/cli/telemetry/cli-command-run.ts @@ -0,0 +1,71 @@ +import { getErrorMessage } from '../errors'; +import type { AddResult } from '../primitives/types.js'; +import { TelemetryClientAccessor } from './client-accessor.js'; +import type { Command, CommandAttrs } from './schemas/command-run.js'; + +/** + * Run a CLI command with telemetry, standardized error output, and process.exit. + * The callback should throw on failure and return telemetry attrs on success. + * + * If telemetry initialization fails, the command still runs without telemetry — + * telemetry must never block CLI behavior. + */ +export async function cliCommandRun( + command: C, + json: boolean, + fn: () => Promise> +): Promise { + try { + let client; + try { + client = await TelemetryClientAccessor.get(); + } catch { + // Telemetry init failed — run without it + await fn(); + process.exit(0); + } + // withCommandRun records success/failure telemetry, then re-throws on failure + await client.withCommandRun(command, fn); + process.exit(0); + } catch (error) { + if (json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(getErrorMessage(error)); + } + process.exit(1); + } +} + +/** + * Wrap a primitive .add() call with telemetry — used by TUI paths. + * CLI paths use {@link cliCommandRun} instead. + */ +export async function withAddTelemetry>( + command: C, + attrs: CommandAttrs, + fn: () => Promise> +): Promise> { + let client; + try { + client = await TelemetryClientAccessor.get(); + } catch { + return fn(); + } + + let result: AddResult | undefined; + try { + await client.withCommandRun(command, async () => { + result = await fn(); + if (!result.success) throw new Error(result.error); + return attrs; + }); + } catch (err) { + // withCommandRun re-throws after recording failure telemetry. + // result is set if fn() ran; if not, fn() itself threw. + if (!result) { + return { success: false, error: getErrorMessage(err) }; + } + } + return result!; +} diff --git a/src/cli/telemetry/client-accessor.ts b/src/cli/telemetry/client-accessor.ts new file mode 100644 index 000000000..04172dae5 --- /dev/null +++ b/src/cli/telemetry/client-accessor.ts @@ -0,0 +1,49 @@ +import { GLOBAL_CONFIG_DIR, readGlobalConfig } from '../../lib/schemas/io/global-config.js'; +import { TelemetryClient } from './client.js'; +import { resolveAuditFilePath, resolveResourceAttributes } from './config.js'; +import { FileSystemSink } from './sinks/filesystem-sink.js'; +import { CompositeSink } from './sinks/metric-sink.js'; +import { join } from 'path'; + +/** + * Manages a singleton TelemetryClient. Call init() at startup to configure, + * get() from command handlers to obtain the client, and shutdown() on exit. + * get() lazily initializes if init() was never called. + */ +export class TelemetryClientAccessor { + private static clientPromise: Promise | undefined; + + static init(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): void { + this.clientPromise = createClient(entrypoint, mode); + } + + static get(): Promise { + this.clientPromise ??= createClient('unknown'); + return this.clientPromise; + } + + static async shutdown(): Promise { + if (this.clientPromise) { + const client = await this.clientPromise; + await client.shutdown(); + } + } +} + +async function createClient(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): Promise { + const [resource, config] = await Promise.all([resolveResourceAttributes(mode), readGlobalConfig()]); + + const sinks = []; + const audit = process.env.AGENTCORE_TELEMETRY_AUDIT === '1' || config.telemetry?.audit === true; + + if (audit) { + const filePath = resolveAuditFilePath( + join(GLOBAL_CONFIG_DIR, 'telemetry'), + entrypoint, + resource['agentcore-cli.session_id'] + ); + sinks.push(new FileSystemSink({ filePath, resource })); + } + + return new TelemetryClient(new CompositeSink(sinks)); +} diff --git a/src/cli/telemetry/client.ts b/src/cli/telemetry/client.ts index 3228f45b1..91dffd94f 100644 --- a/src/cli/telemetry/client.ts +++ b/src/cli/telemetry/client.ts @@ -1,6 +1,6 @@ import { classifyError, isUserError } from './error-classification.js'; import { COMMAND_SCHEMAS, type Command, type CommandAttrs, deriveCommandGroup } from './schemas/command-run.js'; -import { type CommandResult, CommandResultSchema } from './schemas/common-shapes.js'; +import { type CommandResult, CommandResultSchema, resilientParse } from './schemas/common-shapes.js'; import type { MetricSink } from './sinks/metric-sink.js'; import { performance } from 'perf_hooks'; @@ -69,17 +69,24 @@ export class TelemetryClient { durationMs: number ): void { try { + // CommandResult is built internally — hard parse is intentional since + // a metric without a valid exit_reason is meaningless. CommandResultSchema.parse(result); - if (result.exit_reason !== 'failure' && result.exit_reason !== 'cancel') { - COMMAND_SCHEMAS[command].parse(attrs); - } + + // Validate command attrs resiliently: invalid fields default to 'unknown' + // instead of dropping the entire metric. + // On failure/cancel the callback attrs are empty so validation is skipped. + const validatedAttrs = + result.exit_reason !== 'failure' && result.exit_reason !== 'cancel' + ? resilientParse(COMMAND_SCHEMAS[command], attrs as Record) + : attrs; const otelAttrs: Record = { command_group: deriveCommandGroup(command), command, }; - for (const obj of [result, attrs]) { + for (const obj of [result, validatedAttrs]) { for (const [k, v] of Object.entries(obj)) { if (typeof v === 'boolean') { otelAttrs[k] = String(v); diff --git a/src/cli/telemetry/config.ts b/src/cli/telemetry/config.ts index 5bee94eff..fbaa3fb13 100644 --- a/src/cli/telemetry/config.ts +++ b/src/cli/telemetry/config.ts @@ -1,8 +1,9 @@ +import { getOrCreateInstallationId, readGlobalConfig } from '../../lib/schemas/io/global-config.js'; import { PACKAGE_VERSION } from '../constants.js'; -import { getOrCreateInstallationId, readGlobalConfig } from '../global-config.js'; import { type ResourceAttributes, ResourceAttributesSchema } from './schemas/common-attributes.js'; import { randomUUID } from 'crypto'; import os from 'os'; +import { join } from 'path'; // --------------------------------------------------------------------------- // Telemetry preference (opt-in / opt-out) @@ -59,3 +60,7 @@ export async function resolveResourceAttributes(mode: 'cli' | 'tui'): Promise { } }); }); + +describe('resilientParse', () => { + it('passes valid attrs through unchanged', () => { + const attrs = { + language: 'python', + framework: 'strands', + model_provider: 'bedrock', + memory: 'shortterm', + protocol: 'mcp', + build: 'codezip', + agent_type: 'create', + network_mode: 'public', + has_agent: true, + }; + expect(resilientParse(COMMAND_SCHEMAS.create, attrs)).toEqual(attrs); + }); + + it('defaults a single invalid enum field to unknown', () => { + const attrs = { + language: 'rust', // invalid + framework: 'strands', + model_provider: 'bedrock', + memory: 'shortterm', + protocol: 'mcp', + build: 'codezip', + agent_type: 'create', + network_mode: 'public', + has_agent: true, + }; + const result = resilientParse(COMMAND_SCHEMAS.create, attrs); + expect(result.language).toBe('unknown'); + expect(result.framework).toBe('strands'); + }); + + it('defaults missing required fields to unknown', () => { + const result = resilientParse(COMMAND_SCHEMAS.create, { language: 'python' }); + expect(result.language).toBe('python'); + expect(result.framework).toBe('unknown'); + expect(result.model_provider).toBe('unknown'); + }); + + it('defaults all fields to unknown when all are invalid', () => { + const result = resilientParse(COMMAND_SCHEMAS.create, {}); + for (const value of Object.values(result)) { + expect(value).toBe('unknown'); + } + }); + + it('returns empty object for no-attrs schemas', () => { + expect(resilientParse(COMMAND_SCHEMAS['telemetry.disable'], {})).toEqual({}); + }); +}); diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index f8a6df436..0acfcaf1b 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -157,6 +157,7 @@ export const COMMAND_SCHEMAS = { 'add.gateway-target': AddGatewayTargetAttrs, 'add.policy-engine': AddPolicyEngineAttrs, 'add.policy': AddPolicyAttrs, + 'add.runtime-endpoint': NoAttrs, // deploy deploy: DeployAttrs, @@ -193,6 +194,7 @@ export const COMMAND_SCHEMAS = { package: NoAttrs, validate: NoAttrs, 'help.modes': NoAttrs, + help: NoAttrs, 'remove.agent': NoAttrs, 'remove.memory': NoAttrs, 'remove.credential': NoAttrs, diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index 5c5e56493..4624883cd 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -8,6 +8,40 @@ export function safeSchema>(shape: T) { return z.object(shape); } +/** + * Validate each field in a schema individually, defaulting to 'unknown' on failure. + * This ensures a single invalid attribute never blocks the entire metric from being published. + * Keys in attrs not present in the schema are omitted from the result. + */ +export function resilientParse( + schema: z.ZodObject, + attrs: Record +): Record { + const result: Record = {}; + for (const key of Object.keys(schema.shape)) { + const field = schema.shape[key] as z.ZodType; + const parsed = field.safeParse(attrs[key]); + result[key] = parsed.success ? parsed.data : 'unknown'; + } + return result; +} + +/** + * Lowercase a CLI value and parse it through a Zod enum, returning the narrowed type. + * The `as` cast on the failure branch is intentional: invalid values pass through to + * recordCommandRun, where COMMAND_SCHEMAS[command].parse(attrs) validates the full + * attr object in a try/catch — silently dropping the metric if any field is invalid. + * This ensures telemetry never crashes the CLI while keeping the happy-path type-safe. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function standardize>(schema: T, value: string | undefined): z.infer { + const lower = (value ?? '').toLowerCase(); + const result = schema.safeParse(lower); + // If the value doesn't match the enum, return the lowercased value anyway — + // recordCommandRun's try/catch will silently drop the invalid metric. + return (result.success ? result.data : lower) as z.infer; +} + // Primitive types export const Count = z.number().int().nonnegative(); @@ -41,7 +75,17 @@ export const GatewayTargetType = z.enum([ 'open-api-schema', 'smithy-model', 'lambda-function-arn', + 'unknown', ]); + +/** Map camelCase CLI target type to kebab-case telemetry enum value. */ +export const GATEWAY_TARGET_TYPE_MAP: Record> = { + apiGateway: 'api-gateway', + openApiSchema: 'open-api-schema', + smithyModel: 'smithy-model', + lambdaFunctionArn: 'lambda-function-arn', + mcpServer: 'mcp-server', +}; export const Language = z.enum(['python', 'typescript', 'other']); export const Level = z.enum(['session', 'trace', 'tool_call']); export const Memory = z.enum(['none', 'shortterm', 'longandshortterm']); diff --git a/src/cli/telemetry/sinks/filesystem-sink.ts b/src/cli/telemetry/sinks/filesystem-sink.ts new file mode 100644 index 000000000..a9868f2b9 --- /dev/null +++ b/src/cli/telemetry/sinks/filesystem-sink.ts @@ -0,0 +1,48 @@ +import type { MetricSink } from './metric-sink.js'; +import { appendFile, mkdir } from 'fs/promises'; +import { dirname } from 'path'; + +export interface FileSystemSinkConfig { + filePath: string; + resource?: Record; + log?: (message: string) => void; +} + +export class FileSystemSink implements MetricSink { + private readonly filePath: string; + private readonly resource: Record; + private readonly log: (message: string) => void; + private hasRecords = false; + + constructor(config: FileSystemSinkConfig) { + this.filePath = config.filePath; + this.resource = config.resource ?? {}; + this.log = config.log ?? (msg => console.log(msg)); + } + + record(value: number, attrs: Record): void { + this.hasRecords = true; + this.pendingWrite = this.pendingWrite.then(() => + this.appendEntry({ value, attrs: { ...this.resource, ...attrs } }) + ); + } + + async flush(): Promise { + await this.pendingWrite; + } + + async shutdown(): Promise { + await this.pendingWrite; + if (this.hasRecords) { + this.log(`[audit mode] Telemetry written to ${this.filePath}`); + } + } + + // Promise chain that serializes async writes so record() can stay synchronous. + private pendingWrite: Promise = Promise.resolve(); + + private async appendEntry(entry: { value: number; attrs: Record }): Promise { + await mkdir(dirname(this.filePath), { recursive: true }); + await appendFile(this.filePath, JSON.stringify(entry) + '\n'); + } +} diff --git a/src/cli/templates/types.ts b/src/cli/templates/types.ts index 185f7b084..907cc99dd 100644 --- a/src/cli/templates/types.ts +++ b/src/cli/templates/types.ts @@ -72,4 +72,6 @@ export interface AgentRenderConfig { sessionStorageMountPath?: string; /** Whether to wrap entrypoint with opentelemetry-instrument. Defaults to true. */ enableOtel?: boolean; + /** Whether a config bundle is wired into the agent template */ + hasConfigBundle?: boolean; } diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 2f5087329..96b5c7f85 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -4,8 +4,10 @@ import { LayoutProvider } from './context'; import { CLI_ONLY_EXAMPLES } from './copy'; import { setExitAction } from './exit-action'; import { MissingProjectMessage, WrongDirectoryMessage, getProjectRootMismatch, projectExists } from './guards'; +import { ABTestPickerScreen } from './screens/ab-test'; import { AddFlow } from './screens/add/AddFlow'; import { CliOnlyScreen } from './screens/cli-only'; +import { ConfigBundleFlow } from './screens/config-bundle-hub'; import { CreateScreen } from './screens/create'; import { DeployScreen } from './screens/deploy/DeployScreen'; import { EvalHubScreen, EvalScreen } from './screens/eval'; @@ -15,8 +17,9 @@ import { ImportFlow } from './screens/import'; import { InvokeScreen } from './screens/invoke'; import { OnlineEvalDashboard } from './screens/online-eval'; import { PackageScreen } from './screens/package'; +import { RecommendationFlow, RecommendationHistoryScreen, RecommendationsHubScreen } from './screens/recommendation'; import { RemoveFlow } from './screens/remove'; -import { RunEvalFlow, RunScreen } from './screens/run-eval'; +import { BatchEvalHistoryScreen, RunBatchEvalFlow, RunEvalFlow, RunScreen } from './screens/run-eval'; import { StatusScreen } from './screens/status/StatusScreen'; import { UpdateScreen } from './screens/update'; import { ValidateScreen } from './screens/validate'; @@ -38,6 +41,11 @@ type Route = | { name: 'remove' } | { name: 'run' } | { name: 'run-eval'; from?: 'run' | 'evals' } + | { name: 'run-batch-eval'; from?: 'run' | 'evals' } + | { name: 'batch-eval-history' } + | { name: 'recommendations-hub' } + | { name: 'recommend'; from?: 'recommendations-hub' | 'run' } + | { name: 'recommendation-history' } | { name: 'evals' } | { name: 'eval-runs' } | { name: 'online-evals' } @@ -45,7 +53,9 @@ type Route = | { name: 'validate' } | { name: 'package' } | { name: 'update' } + | { name: 'config-bundle' } | { name: 'import' } + | { name: 'ab-test' } | { name: 'cli-only'; commandId: string }; // Commands that don't require being at the project root @@ -111,6 +121,8 @@ function AppContent() { setRoute({ name: 'evals' }); } else if (id === 'fetch') { setRoute({ name: 'fetch-access' }); + } else if (id === 'recommendations') { + setRoute({ name: 'recommendations-hub' }); } else if (id === 'validate') { setRoute({ name: 'validate' }); } else if (id === 'package') { @@ -123,6 +135,10 @@ function AppContent() { setRoute({ name: 'import' }); } else if (id === 'update') { setRoute({ name: 'update' }); + } else if (id === 'config-bundle') { + setRoute({ name: 'config-bundle' }); + } else if (id === 'ab-test') { + setRoute({ name: 'ab-test' }); } }; @@ -213,6 +229,8 @@ function AppContent() { return ( setRoute({ name: 'run-eval', from: 'run' })} + onRunBatchEval={() => setRoute({ name: 'run-batch-eval', from: 'run' })} + onRunRecommendation={() => setRoute({ name: 'recommend', from: 'run' })} onExit={() => setRoute({ name: 'help' })} /> ); @@ -224,6 +242,8 @@ function AppContent() { onSelect={view => { if (view === 'run-eval') setRoute({ name: 'run-eval', from: 'evals' }); if (view === 'runs') setRoute({ name: 'eval-runs' }); + if (view === 'run-batch-eval') setRoute({ name: 'run-batch-eval', from: 'evals' }); + if (view === 'batch-eval-history') setRoute({ name: 'batch-eval-history' }); if (view === 'online-dashboard') setRoute({ name: 'online-evals' }); }} onExit={() => setRoute({ name: 'help' })} @@ -241,6 +261,36 @@ function AppContent() { ); } + if (route.name === 'run-batch-eval') { + const backRoute = route.from ?? 'run'; + return setRoute({ name: backRoute } as Route)} />; + } + + if (route.name === 'batch-eval-history') { + return setRoute({ name: 'evals' })} />; + } + + if (route.name === 'recommendations-hub') { + return ( + { + if (view === 'run-recommendation') setRoute({ name: 'recommend', from: 'recommendations-hub' }); + if (view === 'recommendation-history') setRoute({ name: 'recommendation-history' }); + }} + onExit={() => setRoute({ name: 'help' })} + /> + ); + } + + if (route.name === 'recommend') { + const backRoute = route.from ?? 'recommendations-hub'; + return setRoute({ name: backRoute } as Route)} />; + } + + if (route.name === 'recommendation-history') { + return setRoute({ name: 'recommendations-hub' })} />; + } + if (route.name === 'eval-runs') { return setRoute({ name: 'evals' })} />; } @@ -274,6 +324,14 @@ function AppContent() { return setRoute({ name: 'help' })} />; } + if (route.name === 'config-bundle') { + return setRoute({ name: 'help' })} />; + } + + if (route.name === 'ab-test') { + return setRoute({ name: 'help' })} />; + } + if (route.name === 'cli-only') { const info = CLI_ONLY_EXAMPLES[route.commandId]; if (info) { diff --git a/src/cli/tui/components/DeployStatus.tsx b/src/cli/tui/components/DeployStatus.tsx index 712c9dd95..c6e78b3e4 100644 --- a/src/cli/tui/components/DeployStatus.tsx +++ b/src/cli/tui/components/DeployStatus.tsx @@ -7,6 +7,8 @@ interface DeployStatusProps { messages: DeployMessage[]; isComplete: boolean; hasError: boolean; + hasPostDeployError?: boolean; + postDeployWarnings?: string[]; } const PROGRESS_BAR_WIDTH = 20; @@ -127,7 +129,13 @@ function ResourceLine({ resource }: { resource: ParsedResource }) { * During deployment: shows last N resource events (type + status only) * After completion: shows success/failure state */ -export function DeployStatus({ messages, isComplete, hasError }: DeployStatusProps) { +export function DeployStatus({ + messages, + isComplete, + hasError, + hasPostDeployError, + postDeployWarnings, +}: DeployStatusProps) { // Parse and filter messages to only meaningful resource updates const parsedResources = messages .map(msg => ({ original: msg, parsed: parseResourceMessage(msg) })) @@ -139,16 +147,19 @@ export function DeployStatus({ messages, isComplete, hasError }: DeployStatusPro // When complete, show final status if (isComplete) { + const hasWarning = hasPostDeployError && !hasError; + const borderColor = hasError ? 'red' : hasWarning ? 'yellow' : 'green'; + const textColor = borderColor; + const bannerText = hasError + ? '✗ Deploy to AWS Failed' + : hasWarning + ? '⚠ Deploy to AWS Complete (with warnings)' + : '✓ Deploy to AWS Complete'; + return ( - - - {hasError ? '✗ Deploy to AWS Failed' : '✓ Deploy to AWS Complete'} + + + {bannerText} {progress && ( @@ -162,6 +173,15 @@ export function DeployStatus({ messages, isComplete, hasError }: DeployStatusPro ))} )} + {hasWarning && postDeployWarnings && postDeployWarnings.length > 0 && ( + + {postDeployWarnings.map((w, i) => ( + + {w} + + ))} + + )} ); } diff --git a/src/cli/tui/components/MultiSelectList.tsx b/src/cli/tui/components/MultiSelectList.tsx index 74f6ef2d5..1f2994f22 100644 --- a/src/cli/tui/components/MultiSelectList.tsx +++ b/src/cli/tui/components/MultiSelectList.tsx @@ -6,6 +6,8 @@ export interface MultiSelectListProps { selectedIndex: number; selectedIds: Set; emptyMessage?: string; + /** Maximum number of visible items before scrolling. Undefined = show all. */ + maxVisibleItems?: number; } export function MultiSelectList(props: MultiSelectListProps) { @@ -18,11 +20,30 @@ export function MultiSelectList(props: MultiSelectList ); } + const { items, selectedIndex, selectedIds, maxVisibleItems } = props; + const needsScroll = maxVisibleItems !== undefined && items.length > maxVisibleItems; + + let visibleItems = items; + let viewportStart = 0; + let viewportEnd = items.length; + + if (needsScroll) { + const halfVisible = Math.floor(maxVisibleItems / 2); + viewportStart = Math.max(0, selectedIndex - halfVisible); + viewportEnd = Math.min(items.length, viewportStart + maxVisibleItems); + if (viewportEnd - viewportStart < maxVisibleItems) { + viewportStart = Math.max(0, viewportEnd - maxVisibleItems); + } + visibleItems = items.slice(viewportStart, viewportEnd); + } + return ( - {props.items.map((item, idx) => { - const isCursor = idx === props.selectedIndex; - const isChecked = props.selectedIds.has(item.id); + {needsScroll && viewportStart > 0 && ↑ {viewportStart} more} + {visibleItems.map((item, idx) => { + const actualIndex = viewportStart + idx; + const isCursor = actualIndex === selectedIndex; + const isChecked = selectedIds.has(item.id); const checkbox = isChecked ? '[✓]' : '[ ]'; return ( @@ -35,6 +56,7 @@ export function MultiSelectList(props: MultiSelectList ); })} + {needsScroll && viewportEnd < items.length && ↓ {items.length - viewportEnd} more} ); } diff --git a/src/cli/tui/components/PathInput.tsx b/src/cli/tui/components/PathInput.tsx index ebb16f956..f2663cea4 100644 --- a/src/cli/tui/components/PathInput.tsx +++ b/src/cli/tui/components/PathInput.tsx @@ -209,8 +209,30 @@ export function PathInput({ return; } - // Enter: Validate and submit the current path + // Enter: If a dropdown item is highlighted, select it first; then validate and submit if (key.return) { + // If there's a highlighted match, auto-select it + if (matches.length > 0 && matches[clampedIndex]) { + const selected = matches[clampedIndex]; + if (selected.isDirectory) { + // Drill into directory + setValue(selected.value); + setCursor(selected.value.length); + setSelectedIndex(0); + return; + } + // It's a file — select and submit it + const validationError = allowCreate + ? validatePathForCreate(selected.value, basePath) + : validatePath(selected.value, basePath, pathType); + if (validationError) { + setError(validationError); + return; + } + onSubmit(selected.value); + return; + } + const trimmed = value.trim(); if (!trimmed) { if (allowEmpty) { diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index 44bcf0b77..36504cd62 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -20,6 +20,8 @@ const ICONS = { 'online-eval': '↻', 'policy-engine': '▣', policy: '▢', + 'config-bundle': '⬡', + 'ab-test': '⚗', 'runtime-endpoint': '◉', } as const; @@ -103,7 +105,7 @@ function ResourceRow({ )} {invocationUrl && ( - {' '}URL: {invocationUrl} + {' '}Invocation URL: {invocationUrl} )} @@ -129,6 +131,8 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res const mcpRuntimeTools = mcp?.mcpRuntimeTools ?? []; const unassignedTargets = mcp?.unassignedTargets ?? []; const policyEngines = project.policyEngines ?? []; + const configBundles = project.configBundles ?? []; + const abTests = project.abTests ?? []; // Build lookup map and collect pending-removal resources in a single pass const { statusMap, pendingRemovals } = useMemo(() => { @@ -306,6 +310,49 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res )} + {/* Configuration Bundles */} + {configBundles.length > 0 && ( + + Configuration Bundles + {configBundles.map(bundle => { + const rsEntry = statusMap.get(`config-bundle:${bundle.name}`); + return ( + + ); + })} + + )} + + {/* AB Tests */} + {abTests.length > 0 && ( + + AB Tests + {abTests.map(test => { + const rsEntry = statusMap.get(`ab-test:${test.name}`); + return ( + + ); + })} + + )} + {/* Removed locally — still deployed in AWS, will be torn down on next deploy */} {pendingRemovals.length > 0 && ( @@ -436,7 +483,9 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res {ICONS.evaluator} evaluator{' '} {ICONS['online-eval']} online-eval{' '} {ICONS.gateway} gateway{' '} - {ICONS['policy-engine']} policy engine + {ICONS['policy-engine']} policy engine{' '} + {ICONS['config-bundle']} config bundle{' '} + {ICONS['ab-test']} ab test diff --git a/src/cli/tui/components/SelectList.tsx b/src/cli/tui/components/SelectList.tsx index 6163c102a..feea63248 100644 --- a/src/cli/tui/components/SelectList.tsx +++ b/src/cli/tui/components/SelectList.tsx @@ -13,6 +13,8 @@ export function SelectList(props: { items: T[]; selectedIndex: number; emptyMessage?: string; + /** Maximum number of visible items before scrolling. Undefined = show all. */ + maxVisibleItems?: number; }) { if (props.items.length === 0) { return ( @@ -24,10 +26,29 @@ export function SelectList(props: { ); } + const { items, selectedIndex, maxVisibleItems } = props; + const needsScroll = maxVisibleItems !== undefined && items.length > maxVisibleItems; + + let visibleItems = items; + let viewportStart = 0; + let viewportEnd = items.length; + + if (needsScroll) { + const halfVisible = Math.floor(maxVisibleItems / 2); + viewportStart = Math.max(0, selectedIndex - halfVisible); + viewportEnd = Math.min(items.length, viewportStart + maxVisibleItems); + if (viewportEnd - viewportStart < maxVisibleItems) { + viewportStart = Math.max(0, viewportEnd - maxVisibleItems); + } + visibleItems = items.slice(viewportStart, viewportEnd); + } + return ( - {props.items.map((item, idx) => { - const selected = idx === props.selectedIndex; + {needsScroll && viewportStart > 0 && ↑ {viewportStart} more} + {visibleItems.map((item, idx) => { + const actualIndex = viewportStart + idx; + const selected = actualIndex === selectedIndex; const disabled = item.disabled ?? false; return ( @@ -43,6 +64,7 @@ export function SelectList(props: { ); })} + {needsScroll && viewportEnd < items.length && ↓ {items.length - viewportEnd} more} ); } diff --git a/src/cli/tui/components/WizardSelect.tsx b/src/cli/tui/components/WizardSelect.tsx index bd4343813..184720398 100644 --- a/src/cli/tui/components/WizardSelect.tsx +++ b/src/cli/tui/components/WizardSelect.tsx @@ -16,6 +16,8 @@ interface WizardSelectBaseProps { interface WizardSelectProps extends WizardSelectBaseProps { /** Current selected index */ selectedIndex: number; + /** Maximum visible items before scrolling. Undefined = show all. */ + maxVisibleItems?: number; } interface WizardMultiSelectProps extends WizardSelectBaseProps { @@ -23,6 +25,8 @@ interface WizardMultiSelectProps extends WizardSelectBaseProps { cursorIndex: number; /** Currently selected item IDs */ selectedIds: Set; + /** Maximum visible items before scrolling. Undefined = show all. */ + maxVisibleItems?: number; } /** @@ -39,13 +43,25 @@ interface WizardMultiSelectProps extends WizardSelectBaseProps { * /> * ``` */ -export function WizardSelect({ title, description, items, selectedIndex, emptyMessage }: WizardSelectProps) { +export function WizardSelect({ + title, + description, + items, + selectedIndex, + emptyMessage, + maxVisibleItems, +}: WizardSelectProps) { return ( {title} {description && {description}} - + ); @@ -73,6 +89,7 @@ export function WizardMultiSelect({ cursorIndex, selectedIds, emptyMessage, + maxVisibleItems, }: WizardMultiSelectProps) { return ( @@ -84,6 +101,7 @@ export function WizardMultiSelect({ selectedIndex={cursorIndex} selectedIds={selectedIds} emptyMessage={emptyMessage} + maxVisibleItems={maxVisibleItems} /> 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__/DeployStatus.test.tsx b/src/cli/tui/components/__tests__/DeployStatus.test.tsx index f13ad796a..fedca8e1a 100644 --- a/src/cli/tui/components/__tests__/DeployStatus.test.tsx +++ b/src/cli/tui/components/__tests__/DeployStatus.test.tsx @@ -155,6 +155,55 @@ describe('DeployStatus', () => { }); }); + describe('warning state (post-deploy errors)', () => { + it('shows warning banner when hasPostDeployError is true', () => { + const { lastFrame } = render( + + ); + const frame = lastFrame()!; + + expect(frame).toContain('⚠'); + expect(frame).toContain('Deploy to AWS Complete (with warnings)'); + }); + + it('shows post-deploy warnings in the banner', () => { + const warnings = ['Config bundle "my-bundle": timeout', 'AB test "test-1": not found']; + const { lastFrame } = render( + + ); + const frame = lastFrame()!; + + expect(frame).toContain('Config bundle "my-bundle": timeout'); + expect(frame).toContain('AB test "test-1": not found'); + }); + + it('warning state takes precedence over complete state', () => { + const { lastFrame } = render( + + ); + const frame = lastFrame()!; + + expect(frame).not.toContain('✓ Deploy to AWS Complete'); + expect(frame).toContain('⚠ Deploy to AWS Complete (with warnings)'); + }); + + it('error state takes precedence over warning state', () => { + const { lastFrame } = render( + + ); + const frame = lastFrame()!; + + expect(frame).toContain('✗ Deploy to AWS Failed'); + expect(frame).not.toContain('with warnings'); + }); + }); + describe('error state details', () => { it('shows last 3 resource events on failure', () => { const messages = [ 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'); - }); -}); diff --git a/src/cli/tui/constants.ts b/src/cli/tui/constants.ts index 74762fb91..98ff3841c 100644 --- a/src/cli/tui/constants.ts +++ b/src/cli/tui/constants.ts @@ -36,6 +36,8 @@ export const HELP_TEXT = { STATUS_REFRESH: '↑↓ select · Enter refresh · Esc back · Ctrl+C quit', /** Status screen refresh with target cycling */ STATUS_TARGET_CYCLE: '↑↓ select · Enter refresh · T target · Esc back · Ctrl+C quit', + /** Variant config form */ + VARIANTS_FORM: 'Enter to select · Esc back', } as const; /** diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 2aef3c42c..81185f394 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -41,15 +41,20 @@ export const COMMAND_DESCRIPTIONS = { remove: 'Remove resources from project config.', status: 'Show deployed resource details and status.', traces: 'View and download agent traces.', - evals: 'View past eval run results.', + evals: 'View saved eval and batch eval results from past runs.', fetch: 'Fetch access info for deployed resources.', - pause: 'Pause an online eval config. Supports --arn for configs outside the project.', - resume: 'Resume a paused online eval config. Supports --arn for configs outside the project.', - run: 'Run on-demand evaluation.', + pause: 'Pause a deployed resource (online eval config, A/B test).', + resume: 'Resume a paused resource (online eval config, A/B test).', + recommend: '[preview] Run optimization recommendations for system prompts and tool descriptions.', + recommendations: '[preview] View recommendation history from past runs.', + run: 'Run evaluations, batch evaluations, or optimization recommendations.', + stop: 'Stop a running batch evaluation or A/B test.', import: 'Import a runtime, memory, or starter toolkit into this project. [experimental]', telemetry: 'Manage anonymous usage analytics preferences.', update: 'Check for and install CLI updates', validate: 'Validate agentcore/ config files.', + 'config-bundle': '[preview] Manage configuration bundle versions and diffs.', + archive: '[preview] Archive (delete) a batch evaluation or recommendation on the service and clear local history.', } as const; /** @@ -62,7 +67,7 @@ export const CLI_ONLY_EXAMPLES: Record', 'agentcore resume online-eval --arn '], }, + 'run eval': { + description: 'Run on-demand evaluation of runtime traces against one or more evaluators.', + examples: [ + 'agentcore run eval -r MyAgent -e Builtin.Correctness', + 'agentcore run eval -r MyAgent -e Builtin.Faithfulness --lookback 14', + 'agentcore run eval -r MyAgent -e Builtin.Correctness -A "Must mention pricing" --expected-response "The price is $10"', + 'agentcore run eval --runtime-arn --evaluator-arn --region us-east-1', + ], + }, + 'run batch-evaluation': { + description: 'Run evaluators in batch across all agent sessions found in CloudWatch.', + examples: [ + 'agentcore run batch-evaluation -r MyAgent -e Builtin.Correctness', + 'agentcore run batch-evaluation -r MyAgent -e Builtin.Correctness Builtin.Faithfulness --json', + 'agentcore run batch-evaluation -r MyAgent -e Builtin.Completeness -n "weekly-check"', + ], + }, + 'run recommendation': { + description: 'Optimize system prompts or tool descriptions using agent traces.', + examples: [ + 'agentcore run recommendation -t system-prompt -r MyAgent -e Builtin.Correctness --inline "You are a helpful assistant"', + 'agentcore run recommendation -t system-prompt -r MyAgent -e Builtin.Correctness --prompt-file ./prompt.txt', + 'agentcore run recommendation -t tool-description -r MyAgent --tools "search:Searches the web,calc:Does math"', + 'agentcore run recommendation -t system-prompt -r MyAgent -e Builtin.Correctness --bundle-name MyBundle', + ], + }, + stop: { + description: 'Stop a running batch evaluation or A/B test.', + examples: [ + 'agentcore stop batch-evaluation -i ', + 'agentcore stop batch-evaluation -i --json', + 'agentcore stop ab-test ', + ], + }, + archive: { + description: 'Archive (delete) a batch evaluation or recommendation on the service and clear local history.', + examples: [ + 'agentcore archive batch-evaluation -i ', + 'agentcore archive batch-evaluation -i --region us-west-2', + 'agentcore archive batch-evaluation -i --json', + 'agentcore archive recommendation -i ', + 'agentcore archive recommendation -i --region us-west-2', + 'agentcore archive recommendation -i --json', + ], + }, }; diff --git a/src/cli/tui/hooks/__tests__/usePanelNavigation.test.tsx b/src/cli/tui/hooks/__tests__/usePanelNavigation.test.tsx new file mode 100644 index 000000000..89182b2e5 --- /dev/null +++ b/src/cli/tui/hooks/__tests__/usePanelNavigation.test.tsx @@ -0,0 +1,347 @@ +import { usePanelNavigation } from '../usePanelNavigation.js'; +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const UP_ARROW = '\x1B[A'; +const DOWN_ARROW = '\x1B[B'; +const ENTER = '\r'; +const ESCAPE = '\x1B'; +const TAB = '\t'; + +afterEach(() => vi.restoreAllMocks()); + +// Wrapper component to test the hook via rendering +function PanelNav({ + isActive = true, + fieldCount = 3, + onExit = vi.fn(), + isFieldDisabled, + isFieldAutoCompleted, + onComplete, + onResult, +}: { + isActive?: boolean; + fieldCount?: number; + onExit?: () => void; + isFieldDisabled?: (column: number, field: number) => boolean; + isFieldAutoCompleted?: (column: number, field: number) => boolean; + onComplete?: () => void; + onResult?: (result: ReturnType) => void; +}) { + const result = usePanelNavigation({ + isActive, + fieldCount, + onExit, + isFieldDisabled, + isFieldAutoCompleted, + onComplete, + }); + + onResult?.(result); + + return ( + + col:{result.position.column} field:{result.position.field} layer:{result.position.layer} + + ); +} + +const delay = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); + +describe('usePanelNavigation', () => { + it('starts at column 0, field 0, layer focus', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('col:0'); + expect(lastFrame()).toContain('field:0'); + expect(lastFrame()).toContain('layer:focus'); + }); + + describe('Tab switches columns', () => { + it('Tab switches from column 0 to column 1', async () => { + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(TAB); + await delay(); + + expect(lastFrame()).toContain('col:1'); + }); + + it('Tab switches from column 1 back to column 0', async () => { + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(TAB); // 0 → 1 + await delay(); + stdin.write(TAB); // 1 → 0 + await delay(); + + expect(lastFrame()).toContain('col:0'); + }); + }); + + describe('Up/Down moves between fields', () => { + it('Down moves to next field', async () => { + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(DOWN_ARROW); + await delay(); + + expect(lastFrame()).toContain('field:1'); + }); + + it('Up moves to previous field', async () => { + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(DOWN_ARROW); + stdin.write(DOWN_ARROW); + await delay(); + expect(lastFrame()).toContain('field:2'); + + stdin.write(UP_ARROW); + await delay(); + expect(lastFrame()).toContain('field:1'); + }); + }); + + it('Up at field 0 stays at field 0', async () => { + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(UP_ARROW); + await delay(); + + expect(lastFrame()).toContain('field:0'); + }); + + it('Down at last field stays at last field', async () => { + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(DOWN_ARROW); + stdin.write(DOWN_ARROW); // field 2 (last) + await delay(); + expect(lastFrame()).toContain('field:2'); + + stdin.write(DOWN_ARROW); // should stay + await delay(); + expect(lastFrame()).toContain('field:2'); + }); + + it('Enter activates field (layer → active)', async () => { + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(ENTER); + await delay(); + + expect(lastFrame()).toContain('layer:active'); + }); + + describe('Escape navigation', () => { + it('Escape at field 0 column 0 calls onExit', async () => { + const onExit = vi.fn(); + const { stdin } = render(); + + await delay(); + stdin.write(ESCAPE); + await delay(); + + expect(onExit).toHaveBeenCalledTimes(1); + }); + + it('Escape at field > 0 goes to field 0', async () => { + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(DOWN_ARROW); + stdin.write(DOWN_ARROW); + await delay(); + expect(lastFrame()).toContain('field:2'); + + stdin.write(ESCAPE); + await delay(); + expect(lastFrame()).toContain('field:0'); + }); + + it('Escape at column 1 field 0 goes to column 0', async () => { + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(TAB); // go to column 1 + await delay(); + expect(lastFrame()).toContain('col:1'); + + stdin.write(ESCAPE); + await delay(); + expect(lastFrame()).toContain('col:0'); + expect(lastFrame()).toContain('field:0'); + }); + }); + + describe('deactivate auto-advance', () => { + it('deactivate auto-advances to next field in same column', async () => { + const onResult = vi.fn(); + const { stdin } = render(); + + await delay(); + stdin.write(ENTER); // activate field 0 + await delay(); + + const result = onResult.mock.calls[onResult.mock.calls.length - 1]![0]; + expect(result.position.layer).toBe('active'); + }); + }); + + describe('deactivate behavior', () => { + // Harness that auto-deactivates when activated to test the deactivate advance path + function AutoDeactivateHarness({ fieldCount = 3, onComplete }: { fieldCount?: number; onComplete?: () => void }) { + const nav = usePanelNavigation({ + isActive: true, + fieldCount, + onExit: vi.fn(), + onComplete, + }); + + // When activated, immediately deactivate on next render + React.useEffect(() => { + if (nav.position.layer === 'active') { + nav.deactivate(); + } + }, [nav.position.layer, nav.position.column, nav.position.field, nav.deactivate]); + + return ( + + col:{nav.position.column} field:{nav.position.field} layer:{nav.position.layer} + + ); + } + + it('deactivate at field 0 advances to field 1 in same column', async () => { + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(ENTER); // activate field 0 → auto-deactivate → field 1 + await delay(); + + expect(lastFrame()).toContain('field:1'); + expect(lastFrame()).toContain('col:0'); + expect(lastFrame()).toContain('layer:focus'); + }); + + it('deactivate at last field of column 0 moves to column 1 field 0', async () => { + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(ENTER); // activate field 0 (last in col 0) → auto-deactivate → col 1 field 0 + await delay(); + + expect(lastFrame()).toContain('col:1'); + expect(lastFrame()).toContain('field:0'); + }); + + it('deactivate at last field of column 1 calls onComplete', async () => { + const onComplete = vi.fn(); + const { lastFrame, stdin } = render(); + + await delay(); + // Move to column 1 first + stdin.write(ENTER); // col 0 field 0 → deactivate → col 1 field 0 + await delay(); + + expect(lastFrame()).toContain('col:1'); + expect(lastFrame()).toContain('field:0'); + + stdin.write(ENTER); // col 1 field 0 (last) → deactivate → onComplete + await delay(100); + + expect(onComplete).toHaveBeenCalled(); + }); + }); + + describe('isFieldFocused/isFieldActive/isColumnActive', () => { + it('isFieldFocused returns true for current position in focus layer', () => { + let resultRef: ReturnType | undefined; + render( + { + resultRef = r; + }} + /> + ); + + expect(resultRef!.isFieldFocused(0, 0)).toBe(true); + expect(resultRef!.isFieldFocused(0, 1)).toBe(false); + expect(resultRef!.isFieldFocused(1, 0)).toBe(false); + }); + + it('isFieldActive returns false in focus layer', () => { + let resultRef: ReturnType | undefined; + render( + { + resultRef = r; + }} + /> + ); + + expect(resultRef!.isFieldActive(0, 0)).toBe(false); + }); + + it('isColumnActive returns true for current column', () => { + let resultRef: ReturnType | undefined; + render( + { + resultRef = r; + }} + /> + ); + + expect(resultRef!.isColumnActive(0)).toBe(true); + expect(resultRef!.isColumnActive(1)).toBe(false); + }); + }); + + describe('disabled fields are skipped', () => { + it('Down skips disabled field', async () => { + const isFieldDisabled = (_col: number, field: number) => field === 1; + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(DOWN_ARROW); // should skip field 1 and land on field 2 + await delay(); + + expect(lastFrame()).toContain('field:2'); + }); + + it('Up skips disabled field', async () => { + const isFieldDisabled = (_col: number, field: number) => field === 1; + const { lastFrame, stdin } = render(); + + await delay(); + stdin.write(DOWN_ARROW); // skip 1 → field 2 + await delay(); + expect(lastFrame()).toContain('field:2'); + + stdin.write(UP_ARROW); // skip 1 → field 0 + await delay(); + expect(lastFrame()).toContain('field:0'); + }); + + it('stays in place when all remaining fields are disabled', async () => { + const { lastFrame, stdin } = render( f === 1} />); + + await delay(); + // field 0, only field 1 exists and is disabled → stay at 0 + stdin.write(DOWN_ARROW); + await delay(); + + expect(lastFrame()).toContain('field:0'); + }); + }); +}); diff --git a/src/cli/tui/hooks/useCreateABTest.ts b/src/cli/tui/hooks/useCreateABTest.ts new file mode 100644 index 000000000..e54666074 --- /dev/null +++ b/src/cli/tui/hooks/useCreateABTest.ts @@ -0,0 +1,93 @@ +import type { AddTargetBasedABTestOptions } from '../../primitives/ABTestPrimitive'; +import { abTestPrimitive } from '../../primitives/registry'; +import type { GatewayChoice } from '../screens/ab-test/types'; +import { useCallback, useEffect, useState } from 'react'; + +interface CreateABTestConfig { + name: string; + description?: string; + agent: string; + gatewayChoice?: GatewayChoice; + controlBundle: string; + controlVersion: string; + treatmentBundle: string; + treatmentVersion: string; + controlWeight: number; + treatmentWeight: number; + onlineEval: string; + maxDuration?: number; + enableOnCreate?: boolean; +} + +export function useCreateABTest() { + const [status, setStatus] = useState<{ state: 'idle' | 'loading' | 'success' | 'error'; error?: string }>({ + state: 'idle', + }); + + const create = useCallback(async (config: CreateABTestConfig) => { + setStatus({ state: 'loading' }); + try { + const addResult = await abTestPrimitive.add({ + name: config.name, + description: config.description, + agent: config.agent, + gatewayChoice: config.gatewayChoice, + controlBundle: config.controlBundle, + controlVersion: config.controlVersion, + treatmentBundle: config.treatmentBundle, + treatmentVersion: config.treatmentVersion, + controlWeight: config.controlWeight, + treatmentWeight: config.treatmentWeight, + onlineEval: config.onlineEval, + maxDurationDays: config.maxDuration, + enableOnCreate: config.enableOnCreate, + }); + if (!addResult.success) { + throw new Error(addResult.error ?? 'Failed to create AB test'); + } + setStatus({ state: 'success' }); + return { ok: true as const, testName: config.name }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create AB test.'; + setStatus({ state: 'error', error: message }); + return { ok: false as const, error: message }; + } + }, []); + + const createTargetBased = useCallback(async (config: Omit) => { + setStatus({ state: 'loading' }); + try { + const addResult = await abTestPrimitive.addTargetBased(config); + if (!addResult.success) { + throw new Error(addResult.error ?? 'Failed to create target-based AB test'); + } + setStatus({ state: 'success' }); + return { ok: true as const, testName: config.name }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create target-based AB test.'; + setStatus({ state: 'error', error: message }); + return { ok: false as const, error: message }; + } + }, []); + + const reset = useCallback(() => { + setStatus({ state: 'idle' }); + }, []); + + return { status, createABTest: create, createTargetBasedABTest: createTargetBased, reset }; +} + +export function useExistingABTestNames() { + const [names, setNames] = useState([]); + + useEffect(() => { + void abTestPrimitive.getAllNames().then(setNames); + }, []); + + const refresh = useCallback(async () => { + const result = await abTestPrimitive.getAllNames(); + setNames(result); + }, []); + + return { names, refresh }; +} diff --git a/src/cli/tui/hooks/useCreateConfigBundle.ts b/src/cli/tui/hooks/useCreateConfigBundle.ts new file mode 100644 index 000000000..864501eed --- /dev/null +++ b/src/cli/tui/hooks/useCreateConfigBundle.ts @@ -0,0 +1,59 @@ +import { configBundlePrimitive } from '../../primitives/registry'; +import { useCallback, useEffect, useState } from 'react'; + +interface CreateConfigBundleConfig { + name: string; + description?: string; + components: Record }>; + branchName?: string; + commitMessage?: string; +} + +export function useCreateConfigBundle() { + const [status, setStatus] = useState<{ state: 'idle' | 'loading' | 'success' | 'error'; error?: string }>({ + state: 'idle', + }); + + const create = useCallback(async (config: CreateConfigBundleConfig) => { + setStatus({ state: 'loading' }); + try { + const addResult = await configBundlePrimitive.add({ + name: config.name, + description: config.description, + components: config.components, + branchName: config.branchName, + commitMessage: config.commitMessage, + }); + if (!addResult.success) { + throw new Error(addResult.error ?? 'Failed to create configuration bundle'); + } + setStatus({ state: 'success' }); + return { ok: true as const, bundleName: config.name }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create configuration bundle.'; + setStatus({ state: 'error', error: message }); + return { ok: false as const, error: message }; + } + }, []); + + const reset = useCallback(() => { + setStatus({ state: 'idle' }); + }, []); + + return { status, createConfigBundle: create, reset }; +} + +export function useExistingConfigBundleNames() { + const [names, setNames] = useState([]); + + useEffect(() => { + void configBundlePrimitive.getAllNames().then(setNames); + }, []); + + const refresh = useCallback(async () => { + const result = await configBundlePrimitive.getAllNames(); + setNames(result); + }, []); + + return { names, refresh }; +} diff --git a/src/cli/tui/hooks/useCreateEvaluator.ts b/src/cli/tui/hooks/useCreateEvaluator.ts index 6e1d8f052..f1cad666f 100644 --- a/src/cli/tui/hooks/useCreateEvaluator.ts +++ b/src/cli/tui/hooks/useCreateEvaluator.ts @@ -1,5 +1,7 @@ import type { EvaluatorConfig } from '../../../schema'; import { evaluatorPrimitive } from '../../primitives/registry'; +import { withAddTelemetry } from '../../telemetry/cli-command-run.js'; +import { Level, standardize } from '../../telemetry/schemas/common-shapes.js'; import { useCallback, useEffect, useState } from 'react'; interface CreateEvaluatorConfig { @@ -16,11 +18,19 @@ export function useCreateEvaluator() { const create = useCallback(async (config: CreateEvaluatorConfig) => { setStatus({ state: 'loading' }); try { - const addResult = await evaluatorPrimitive.add({ - name: config.name, - level: config.level as 'SESSION' | 'TRACE' | 'TOOL_CALL', - config: config.config, - }); + const addResult = await withAddTelemetry( + 'add.evaluator', + { + evaluator_type: config.config.codeBased ? 'code-based' : 'llm-as-a-judge', + level: standardize(Level, config.level), + }, + () => + evaluatorPrimitive.add({ + name: config.name, + level: config.level as 'SESSION' | 'TRACE' | 'TOOL_CALL', + config: config.config, + }) + ); if (!addResult.success) { throw new Error(addResult.error ?? 'Failed to create evaluator'); } diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index 2b3b3b25a..ec91666d0 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -4,6 +4,8 @@ import { gatewayTargetPrimitive, policyEnginePrimitive, } from '../../primitives/registry'; +import { withAddTelemetry } from '../../telemetry/cli-command-run.js'; +import { AuthorizerType, PolicyEngineMode, standardize } from '../../telemetry/schemas/common-shapes.js'; import type { AddGatewayConfig } from '../screens/mcp/types'; import { useCallback, useEffect, useState } from 'react'; @@ -23,22 +25,33 @@ export function useCreateGateway() { const createGateway = useCallback(async (config: AddGatewayConfig) => { setStatus({ state: 'loading' }); try { - const addResult = await gatewayPrimitive.add({ - name: config.name, - description: config.description, - authorizerType: config.authorizerType, - discoveryUrl: config.jwtConfig?.discoveryUrl, - allowedAudience: config.jwtConfig?.allowedAudience?.join(','), - allowedClients: config.jwtConfig?.allowedClients?.join(','), - allowedScopes: config.jwtConfig?.allowedScopes?.join(','), - customClaims: config.jwtConfig?.customClaims, - clientId: config.jwtConfig?.clientId, - clientSecret: config.jwtConfig?.clientSecret, - enableSemanticSearch: config.enableSemanticSearch, - exceptionLevel: config.exceptionLevel, - policyEngine: config.policyEngineConfiguration?.policyEngineName, - policyEngineMode: config.policyEngineConfiguration?.mode, - }); + const addResult = await withAddTelemetry( + 'add.gateway', + { + authorizer_type: standardize(AuthorizerType, config.authorizerType ?? 'NONE'), + has_policy_engine: !!config.policyEngineConfiguration?.policyEngineName, + policy_engine_mode: standardize(PolicyEngineMode, config.policyEngineConfiguration?.mode ?? 'log_only'), + semantic_search: config.enableSemanticSearch !== false, + runtime_count: 0, + }, + () => + gatewayPrimitive.add({ + name: config.name, + description: config.description, + authorizerType: config.authorizerType, + discoveryUrl: config.jwtConfig?.discoveryUrl, + allowedAudience: config.jwtConfig?.allowedAudience?.join(','), + allowedClients: config.jwtConfig?.allowedClients?.join(','), + allowedScopes: config.jwtConfig?.allowedScopes?.join(','), + customClaims: config.jwtConfig?.customClaims, + clientId: config.jwtConfig?.clientId, + clientSecret: config.jwtConfig?.clientSecret, + enableSemanticSearch: config.enableSemanticSearch, + exceptionLevel: config.exceptionLevel, + policyEngine: config.policyEngineConfiguration?.policyEngineName, + policyEngineMode: config.policyEngineConfiguration?.mode, + }) + ); if (!addResult.success) { throw new Error(addResult.error ?? 'Failed to create gateway'); } diff --git a/src/cli/tui/hooks/useCreateMemory.ts b/src/cli/tui/hooks/useCreateMemory.ts index 4345b4ead..d4196582f 100644 --- a/src/cli/tui/hooks/useCreateMemory.ts +++ b/src/cli/tui/hooks/useCreateMemory.ts @@ -2,6 +2,7 @@ import { ConfigIO } from '../../../lib'; import type { Memory } from '../../../schema'; import { getAvailableAgents } from '../../operations/attach'; import { memoryPrimitive } from '../../primitives/registry'; +import { withAddTelemetry } from '../../telemetry/cli-command-run.js'; import { useCallback, useEffect, useState } from 'react'; interface CreateMemoryConfig { @@ -24,13 +25,25 @@ export function useCreateMemory() { setStatus({ state: 'loading' }); try { const strategiesStr = config.strategies.map(s => s.type).join(','); - const addResult = await memoryPrimitive.add({ - name: config.name, - expiry: config.eventExpiryDuration, - strategies: strategiesStr || undefined, - dataStreamArn: config.streaming?.dataStreamArn, - contentLevel: config.streaming?.contentLevel, - }); + const strategyList = strategiesStr ? strategiesStr.split(',').map(s => s.trim().toUpperCase()) : []; + const addResult = await withAddTelemetry( + 'add.memory', + { + strategy_count: strategyList.length, + strategy_semantic: strategyList.includes('SEMANTIC'), + strategy_summarization: strategyList.includes('SUMMARIZATION'), + strategy_user_preference: strategyList.includes('USER_PREFERENCE'), + strategy_episodic: strategyList.includes('EPISODIC'), + }, + () => + memoryPrimitive.add({ + name: config.name, + expiry: config.eventExpiryDuration, + strategies: strategiesStr || undefined, + dataStreamArn: config.streaming?.dataStreamArn, + contentLevel: config.streaming?.contentLevel, + }) + ); if (!addResult.success) { throw new Error(addResult.error ?? 'Failed to create memory'); } diff --git a/src/cli/tui/hooks/useCreateOnlineEval.ts b/src/cli/tui/hooks/useCreateOnlineEval.ts index 2d0190552..b853fed05 100644 --- a/src/cli/tui/hooks/useCreateOnlineEval.ts +++ b/src/cli/tui/hooks/useCreateOnlineEval.ts @@ -1,11 +1,14 @@ import { onlineEvalConfigPrimitive } from '../../primitives/registry'; +import { withAddTelemetry } from '../../telemetry/cli-command-run.js'; import { useCallback, useEffect, useState } from 'react'; interface CreateOnlineEvalConfig { name: string; agent: string; + endpoint?: string; evaluators: string[]; samplingRate: number; + sessionTimeoutMinutes?: number; enableOnCreate: boolean; } @@ -17,13 +20,23 @@ export function useCreateOnlineEval() { const create = useCallback(async (config: CreateOnlineEvalConfig) => { setStatus({ state: 'loading' }); try { - const addResult = await onlineEvalConfigPrimitive.add({ - name: config.name, - agent: config.agent, - evaluators: config.evaluators, - samplingRate: config.samplingRate, - enableOnCreate: config.enableOnCreate, - }); + const addResult = await withAddTelemetry( + 'add.online-eval', + { + evaluator_count: config.evaluators.length, + enable_on_create: config.enableOnCreate ?? false, + }, + () => + onlineEvalConfigPrimitive.add({ + name: config.name, + agent: config.agent, + ...(config.endpoint ? { endpoint: config.endpoint } : {}), + evaluators: config.evaluators, + samplingRate: config.samplingRate, + ...(config.sessionTimeoutMinutes !== undefined && { sessionTimeoutMinutes: config.sessionTimeoutMinutes }), + enableOnCreate: config.enableOnCreate, + }) + ); if (!addResult.success) { throw new Error(addResult.error ?? 'Failed to create online eval config'); } diff --git a/src/cli/tui/hooks/usePanelNavigation.ts b/src/cli/tui/hooks/usePanelNavigation.ts new file mode 100644 index 000000000..1e06157ac --- /dev/null +++ b/src/cli/tui/hooks/usePanelNavigation.ts @@ -0,0 +1,196 @@ +import { useInput } from 'ink'; +import { useCallback, useState } from 'react'; + +export interface PanelPosition { + column: 0 | 1; + field: number; + layer: 'focus' | 'active'; +} + +interface UsePanelNavigationOptions { + /** Only capture input when the builder step is active */ + isActive: boolean; + /** Number of fields per column */ + fieldCount: number; + /** Called when Escape is pressed at the top-left origin */ + onExit: () => void; + /** Optional check whether a field is disabled (non-focusable) */ + isFieldDisabled?: (column: number, field: number) => boolean; + /** Optional check whether a field is auto-completed (skip on navigation) */ + isFieldAutoCompleted?: (column: number, field: number) => boolean; + /** Called when the last field in the last column is completed */ + onComplete?: () => void; +} + +interface UsePanelNavigationResult { + position: PanelPosition; + /** Whether the given field is the currently focused field */ + isFieldFocused: (column: number, field: number) => boolean; + /** Whether the given field has its picker/input open */ + isFieldActive: (column: number, field: number) => boolean; + /** Whether the given column is the active column */ + isColumnActive: (column: number) => boolean; + /** Open the picker/input for the currently focused field */ + activate: () => void; + /** Close the picker/input, returning to field focus */ + deactivate: () => void; + /** Move focus to a specific field */ + moveToField: (column: number, field: number) => void; +} + +/** + * 2D focus management hook for a side-by-side panel builder. + * + * Navigation model: + * - Tab switches columns (0 <-> 1) + * - Up/Down moves between fields within the active column + * - Enter activates the focused field (layer -> 'active') + * - Escape deactivates or navigates back + * + * When layer === 'active', the hook yields input to child components + * by setting its own `useInput` to inactive. + */ +export function usePanelNavigation({ + isActive, + fieldCount, + onExit, + isFieldDisabled, + isFieldAutoCompleted: _isFieldAutoCompleted, + onComplete, +}: UsePanelNavigationOptions): UsePanelNavigationResult { + const [position, setPosition] = useState({ + column: 0, + field: 0, + layer: 'focus', + }); + + // Only handle input when at focus layer and the panel is active + const inputActive = isActive && position.layer === 'focus'; + + useInput( + (input, key) => { + // Tab: switch columns + if (key.tab) { + setPosition(p => ({ + ...p, + column: p.column === 0 ? 1 : 0, + })); + return; + } + + // Up: move to previous field + if (key.upArrow) { + setPosition(p => { + let next = p.field - 1; + // Skip disabled fields going up + while (next >= 0 && isFieldDisabled?.(p.column, next)) { + next--; + } + if (next < 0) return p; + return { ...p, field: next }; + }); + return; + } + + // Down: move to next field + if (key.downArrow) { + setPosition(p => { + let next = p.field + 1; + // Skip disabled fields going down + while (next < fieldCount && isFieldDisabled?.(p.column, next)) { + next++; + } + if (next >= fieldCount) return p; + return { ...p, field: next }; + }); + return; + } + + // Enter: always activate the focused field (open picker) + if (key.return) { + setPosition(p => ({ ...p, layer: 'active' })); + return; + } + + // Escape: navigate back through the hierarchy + if (key.escape) { + setPosition(p => { + // If not at field 0, go to field 0 in same column + if (p.field > 0) { + return { ...p, field: 0 }; + } + // If at field 0 but not column 0, go to column 0 + if (p.column > 0) { + return { ...p, column: 0 }; + } + // At origin: exit + onExit(); + return p; + }); + return; + } + }, + { isActive: inputActive } + ); + + const isFieldFocused = useCallback( + (column: number, field: number): boolean => { + return position.column === column && position.field === field && position.layer === 'focus'; + }, + [position] + ); + + const isFieldActive = useCallback( + (column: number, field: number): boolean => { + return position.column === column && position.field === field && position.layer === 'active'; + }, + [position] + ); + + const isColumnActive = useCallback( + (column: number): boolean => { + return position.column === column; + }, + [position.column] + ); + + const activate = useCallback(() => { + setPosition(p => ({ ...p, layer: 'active' })); + }, []); + + const deactivate = useCallback(() => { + setPosition(p => { + // After a selection, advance to the next field in sequence: + // column 0 fields 0→1→2, then column 1 fields 0→1→2, then complete + const nextField = p.field + 1; + if (nextField < fieldCount) { + // Next field in same column + return { column: p.column, field: nextField, layer: 'focus' }; + } + if (p.column === 0) { + // Finished left column → move to right column field 0 + return { column: 1, field: 0, layer: 'focus' }; + } + // Finished last field in right column → stay and let onComplete handle it + if (onComplete) { + // Use setTimeout to avoid setState during render + setTimeout(onComplete, 0); + } + return { ...p, layer: 'focus' }; + }); + }, [fieldCount, onComplete]); + + const moveToField = useCallback((column: number, field: number) => { + setPosition({ column: column as 0 | 1, field, layer: 'focus' }); + }, []); + + return { + position, + isFieldFocused, + isFieldActive, + isColumnActive, + activate, + deactivate, + moveToField, + }; +} diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index 8331d38e6..7682479d3 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -6,7 +6,9 @@ import type { RemovableMemory } from '../../primitives/MemoryPrimitive'; import type { RemovablePolicyResource } from '../../primitives/PolicyPrimitive'; import type { RemovableRuntimeEndpoint } from '../../primitives/RuntimeEndpointPrimitive'; import { + abTestPrimitive, agentPrimitive, + configBundlePrimitive, credentialPrimitive, evaluatorPrimitive, gatewayPrimitive, @@ -150,6 +152,24 @@ export function useRemovablePolicies() { return { policies, ...rest }; } +export function useRemovableConfigBundles() { + const { items: configBundles, ...rest } = useRemovableResources(() => configBundlePrimitive.getRemovable()); + return { configBundles, ...rest }; +} + +export function useRemovableABTests() { + const { items: abTests, ...rest } = useRemovableResources(() => abTestPrimitive.getRemovable()); + return { abTests, ...rest }; +} + +export function useRemoveABTest() { + return useRemoveResource( + (name: string) => abTestPrimitive.remove(name), + 'ab-test', + name => name + ); +} + export function useRemovableRuntimeEndpoints() { const { items: endpoints, ...rest } = useRemovableResources(() => runtimeEndpointPrimitive.getRemovable() @@ -228,6 +248,16 @@ export function useRemovalPreview() { (compositeKey: string) => loadPreview(k => policyPrimitive.previewRemove(k), compositeKey), [loadPreview] ); + const loadConfigBundlePreview = useCallback( + (name: string) => loadPreview(n => configBundlePrimitive.previewRemove(n), name), + [loadPreview] + ); + + const loadABTestPreview = useCallback( + (name: string) => loadPreview(n => abTestPrimitive.previewRemove(n), name), + [loadPreview] + ); + const loadRuntimeEndpointPreview = useCallback( (name: string) => loadPreview(n => runtimeEndpointPrimitive.previewRemove(n), name), [loadPreview] @@ -248,6 +278,8 @@ export function useRemovalPreview() { loadOnlineEvalPreview, loadPolicyEnginePreview, loadPolicyPreview, + loadConfigBundlePreview, + loadABTestPreview, loadRuntimeEndpointPreview, reset, }; @@ -336,6 +368,14 @@ export function useRemovePolicy() { ); } +export function useRemoveConfigBundle() { + return useRemoveResource( + (name: string) => configBundlePrimitive.remove(name), + 'config-bundle', + name => name + ); +} + export function useRemoveRuntimeEndpoint() { return useRemoveResource( (name: string) => runtimeEndpointPrimitive.remove(name), diff --git a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx new file mode 100644 index 000000000..36b36ee00 --- /dev/null +++ b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx @@ -0,0 +1,623 @@ +import { ConfigIO } from '../../../../lib'; +import { getCredentialProvider } from '../../../aws/account'; +import { getABTest, updateABTest } from '../../../aws/agentcore-ab-tests'; +import type { GetABTestResult } from '../../../aws/agentcore-ab-tests'; +import { getOnlineEvaluationConfig } from '../../../aws/agentcore-control'; +import { getHttpGateway, listHttpGatewayTargets } from '../../../aws/agentcore-http-gateways'; +import { dnsSuffix } from '../../../aws/partition'; +import { getErrorMessage } from '../../../errors'; +import { GradientText, Screen } from '../../components'; +import { CloudWatchLogsClient, FilterLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs'; +import { Box, Text, useInput } from 'ink'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +interface ABTestDetailScreenProps { + abTestId: string; + region: string; + onExit: () => void; +} + +/** Derive the gateway URL from a gateway ARN. */ +function gatewayUrlFromArn(arn: string): string { + const parts = arn.split(':'); + const region = parts[3]; + const gatewayId = parts[5]?.split('/')[1]; + if (region && gatewayId) { + return `https://${gatewayId}.gateway.bedrock-agentcore.${region}.${dnsSuffix(region)}`; + } + return arn; +} + +/** Extract the resource ID from an ARN (last segment after / or :). */ +function extractId(arn: string): string { + const slashIdx = arn.lastIndexOf('/'); + if (slashIdx !== -1) return arn.slice(slashIdx + 1); + const colonIdx = arn.lastIndexOf(':'); + if (colonIdx !== -1) return arn.slice(colonIdx + 1); + return arn; +} + +/** Truncate a version ID to 8 characters. */ +function shortVersion(version: string): string { + return version.slice(0, 8); +} + +/** Format a Unix epoch timestamp (seconds) to a UTC date string. */ +function formatTimestamp(ts: string | number): string { + const ms = typeof ts === 'string' ? parseFloat(ts) * 1000 : ts * 1000; + const d = new Date(ms); + return d + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ' UTC'); +} + +/** Build a horizontal rule with optional left label and right label. */ +function rule(left?: string, right?: string, width = 48): string { + if (!left && !right) return '─'.repeat(width); + const leftPart = left ? `── ${left} ` : '──'; + const rightPart = right ? ` ${right} ──` : ''; + const fillLen = width - leftPart.length - rightPart.length; + const fill = fillLen > 0 ? '─'.repeat(fillLen) : ''; + return `${leftPart}${fill}${rightPart}`; +} + +interface DebugCheckResult { + label: string; + status: 'pass' | 'fail' | 'warn'; + detail: string; +} + +async function runDebugChecks(test: GetABTestResult, region: string): Promise { + const results: DebugCheckResult[] = []; + const logsClient = new CloudWatchLogsClient({ region, credentials: getCredentialProvider() }); + + // 1. AB Test Status + results.push({ + label: 'AB Test Status', + status: test.status === 'ACTIVE' && test.executionStatus === 'RUNNING' ? 'pass' : 'warn', + detail: `${test.status} / ${test.executionStatus}`, + }); + + // 1b. AB Test Role + results.push({ + label: 'AB Test Role', + status: test.roleArn ? 'pass' : 'warn', + detail: test.roleArn ?? 'No role ARN', + }); + + // 2. Online Eval Config(s) + const evalConfigArns: { name: string; arn: string }[] = + 'perVariantOnlineEvaluationConfig' in test.evaluationConfig + ? test.evaluationConfig.perVariantOnlineEvaluationConfig.map(v => ({ + name: v.name, + arn: v.onlineEvaluationConfigArn, + })) + : [{ name: '', arn: test.evaluationConfig.onlineEvaluationConfigArn }]; + + for (const { name: variantName, arn: evalArn } of evalConfigArns) { + const evalConfigId = extractId(evalArn); + const labelSuffix = variantName ? ` (${variantName})` : ''; + try { + const evalConfig = await getOnlineEvaluationConfig({ region, configId: evalConfigId }); + results.push({ + label: `Online Eval Config${labelSuffix}`, + status: evalConfig.executionStatus === 'ENABLED' ? 'pass' : 'fail', + detail: `${evalConfig.configName} — ${evalConfig.executionStatus}`, + }); + } catch (err) { + results.push({ label: `Online Eval Config${labelSuffix}`, status: 'fail', detail: getErrorMessage(err) }); + } + } + + // 2b. Gateway Role + const gatewayId = extractId(test.gatewayArn); + try { + const gateway = await getHttpGateway({ region, gatewayId }); + results.push({ + label: 'Gateway Role', + status: gateway.roleArn ? 'pass' : 'warn', + detail: gateway.roleArn ?? 'No role ARN', + }); + } catch (err) { + results.push({ label: 'Gateway Role', status: 'fail', detail: getErrorMessage(err) }); + } + + // 5. Runtime spans — check for experiment metadata per variant in aws/spans + // service.name in spans follows the pattern: {projectName}_{agentName}.{endpoint} + // We derive the service name prefix from the deployed state runtimeId (strip random suffix). + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + const variantNames = test.variants.map(v => v.name); + let serviceNamePrefix: string | undefined; + try { + const configIO = new ConfigIO(); + const deployedState = await configIO.readDeployedState(); + for (const [, target] of Object.entries(deployedState.targets ?? {})) { + const runtimes = target.resources?.runtimes ?? {}; + const firstRuntime = Object.values(runtimes)[0]; + if (firstRuntime?.runtimeId) { + // runtimeId is "{projectName}_{agentName}-{randomSuffix}", strip the suffix + serviceNamePrefix = firstRuntime.runtimeId.replace(/-[^-]+$/, ''); + break; + } + } + } catch { + // Fall back to abTestArn-only filtering if deployed state isn't readable + } + + try { + const baseFilter = serviceNamePrefix ? `"${serviceNamePrefix}"` : '"gen_ai_agent"'; + const [allRuntimeSpans, ...variantSpanResults] = await Promise.all([ + logsClient.send( + new FilterLogEventsCommand({ + logGroupName: 'aws/spans', + startTime: twoHoursAgo, + filterPattern: baseFilter, + limit: 1, + }) + ), + ...variantNames.map(name => + logsClient.send( + new FilterLogEventsCommand({ + logGroupName: 'aws/spans', + startTime: twoHoursAgo, + filterPattern: `"${test.abTestArn}" "${name}"`, + limit: 50, + }) + ) + ), + ]); + + const hasRuntimeSpans = (allRuntimeSpans.events?.length ?? 0) > 0; + const totalExperimentSpans = variantSpanResults.reduce((sum, r) => sum + (r.events?.length ?? 0), 0); + + for (let i = 0; i < variantNames.length; i++) { + const name = variantNames[i]; + const count = variantSpanResults[i]?.events?.length ?? 0; + const label = `Runtime Experiment Spans — ${name} (2h)`; + + if (count > 0) { + results.push({ label, status: 'pass', detail: `${count} spans with experiment metadata` }); + } else if (hasRuntimeSpans) { + results.push({ + label, + status: 'warn', + detail: + totalExperimentSpans > 0 + ? `No spans for ${name} — traffic may not be reaching this variant` + : 'Runtime spans found but no experiment metadata — update bedrock-agentcore SDK to the latest version', + }); + } else { + results.push({ label, status: 'warn', detail: 'No runtime spans found — send traffic to the gateway first' }); + } + } + } catch (err) { + results.push({ label: 'Runtime Experiment Spans', status: 'fail', detail: getErrorMessage(err) }); + } + + // 6. Eval Results — check each eval config's log group + const thirtyMinAgo = Date.now() - 30 * 60 * 1000; + for (const { name: variantName, arn: evalArn } of evalConfigArns) { + const configId = extractId(evalArn); + const labelSuffix = variantName ? ` (${variantName})` : ''; + try { + const evalLogGroup = `/aws/bedrock-agentcore/evaluations/results/${configId}`; + + const [allEvents, taggedEvents] = await Promise.all([ + logsClient.send(new FilterLogEventsCommand({ logGroupName: evalLogGroup, startTime: thirtyMinAgo, limit: 1 })), + logsClient.send( + new FilterLogEventsCommand({ + logGroupName: evalLogGroup, + startTime: thirtyMinAgo, + filterPattern: `"${test.abTestArn}"`, + limit: 100, + }) + ), + ]); + + const hasResults = (allEvents.events?.length ?? 0) > 0; + const taggedCount = taggedEvents.events?.length ?? 0; + + if (!hasResults) { + results.push({ + label: `Eval Results${labelSuffix}`, + status: 'warn', + detail: 'No eval results yet — wait ~5m after session timeout for evaluator to process', + }); + } else { + results.push({ + label: `Eval Results${labelSuffix}`, + status: taggedCount > 0 ? 'pass' : 'warn', + detail: + taggedCount > 0 + ? `${taggedCount} results tagged with AB test` + : 'Results exist but none tagged with variant — check gateway trace delivery', + }); + } + } catch (err) { + const msg = getErrorMessage(err); + results.push({ + label: `Eval Results${labelSuffix}`, + status: msg.includes('ResourceNotFoundException') ? 'warn' : 'fail', + detail: msg.includes('ResourceNotFoundException') ? 'Log group not found — evaluator has not run yet' : msg, + }); + } + } + + // 6. Aggregation Results + const metrics = test.results?.evaluatorMetrics ?? []; + const reporting = metrics.filter(m => m.controlStats?.sampleSize > 0); + results.push({ + label: 'Aggregation Results', + status: reporting.length > 0 ? 'pass' : 'warn', + detail: + reporting.length > 0 + ? `${reporting.length} evaluator(s) reporting` + : 'No aggregation data yet — wait ~12-15m after traffic', + }); + + return results; +} + +export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScreenProps) { + const [test, setTest] = useState(null); + const [error, setError] = useState(null); + const [actionMessage, setActionMessage] = useState(null); + const [confirmingStop, setConfirmingStop] = useState(false); + const [confirmingPromote, setConfirmingPromote] = useState(false); + const [debugResults, setDebugResults] = useState(null); + const [debugLoading, setDebugLoading] = useState(false); + const [targetName, setTargetName] = useState(''); + + const hasFetched = useRef(false); + useEffect(() => { + if (hasFetched.current) return; + hasFetched.current = true; + const load = async () => { + try { + const result = await getABTest({ region, abTestId }); + setTest(result); + + // Fetch gateway target name for invocation URL + const gwId = extractId(result.gatewayArn); + try { + const targets = await listHttpGatewayTargets({ region, gatewayId: gwId, maxResults: 1 }); + const firstTarget = targets.targets[0]; + if (firstTarget) setTargetName(firstTarget.name); + } catch { + // Best-effort — URL will show without target path + } + } catch (err) { + setError(getErrorMessage(err)); + } + }; + void load(); + }, [region, abTestId]); + + const performAction = useCallback( + async (targetStatus: 'PAUSED' | 'RUNNING' | 'STOPPED', label: string) => { + setActionMessage(`${label}...`); + try { + await updateABTest({ region, abTestId, executionStatus: targetStatus }); + // Poll until status updates or max attempts reached + for (let i = 0; i < 5; i++) { + await new Promise(resolve => setTimeout(resolve, 1000)); + const result = await getABTest({ region, abTestId }); + setTest(result); + if (result.executionStatus === targetStatus) { + setActionMessage(label.replace('...', 'd').replace('ing', 'ed')); + return; + } + } + // Final fetch even if status didn't converge + setActionMessage(label.replace('ing', 'ed')); + } catch (err: unknown) { + setActionMessage(`Error: ${getErrorMessage(err)}`); + } + }, + [region, abTestId] + ); + + useInput((input, _key) => { + if (!test) return; + + if (confirmingStop) { + if (input === 'y' || input === 'Y') { + setConfirmingStop(false); + void performAction('STOPPED', 'Stopping'); + } else { + setConfirmingStop(false); + } + return; + } + + if (confirmingPromote) { + if (input === 'y' || input === 'Y') { + setConfirmingPromote(false); + setActionMessage('Promoting...'); + void (async () => { + try { + // Stop the AB test + await updateABTest({ region, abTestId, executionStatus: 'STOPPED' }); + for (let i = 0; i < 5; i++) { + await new Promise(resolve => setTimeout(resolve, 1000)); + const result = await getABTest({ region, abTestId }); + setTest(result); + if (result.executionStatus === 'STOPPED') break; + } + + // Apply promotion to agentcore.json + let promotionDetail = ''; + try { + const { promoteABTestConfig } = await import('../../../operations/ab-test/promote'); + const promoResult = await promoteABTestConfig(abTestId, test.name); + promotionDetail = promoResult.promoted + ? `${promoResult.promotionDetail} Run \`agentcore deploy\` to apply.` + : promoResult.promotionDetail; + } catch { + // Config update failed — still report the stop + } + + setActionMessage(promotionDetail || 'AB test stopped. Run `agentcore deploy` to apply.'); + } catch (err) { + setActionMessage(`Error: ${getErrorMessage(err)}`); + } + })(); + } else { + setConfirmingPromote(false); + } + return; + } + + if (input === 'p' || input === 'P') { + void performAction('PAUSED', 'Pausing'); + } + + if (input === 'r' || input === 'R') { + void performAction('RUNNING', 'Resuming'); + } + + if (input === 's' || input === 'S') { + setConfirmingStop(true); + setActionMessage(null); + } + + if (input === 'w' || input === 'W') { + setConfirmingPromote(true); + setActionMessage(null); + } + + if (input === 'd' || input === 'D') { + setDebugLoading(true); + setDebugResults(null); + void runDebugChecks(test, region) + .then(results => { + setDebugResults(results); + setDebugLoading(false); + }) + .catch(() => { + setDebugResults([{ label: 'Debug', status: 'fail' as const, detail: 'Diagnostics failed to run' }]); + setDebugLoading(false); + }); + } + }); + + if (error) { + return ( + + {`Error: ${error}`} + + ); + } + + if (!test) { + return ( + + Loading... + + ); + } + + const controlVariant = test.variants.find(v => v.name === 'C'); + const treatmentVariant = test.variants.find(v => v.name === 'T1'); + + const executionColor = + test.executionStatus === 'RUNNING' ? 'green' : test.executionStatus === 'PAUSED' ? 'yellow' : 'red'; + + const helpParts: string[] = []; + if (test.executionStatus === 'RUNNING') { + helpParts.push('P pause', 'S stop', 'W promote'); + } else if (test.executionStatus === 'PAUSED') { + helpParts.push('R resume', 'S stop', 'W promote'); + } + helpParts.push('D debug', 'Esc exit'); + const helpKeys = helpParts.join(' · '); + + // Build status text: only show provisioning status if not ACTIVE + const statusPrefix = test.status !== 'ACTIVE' ? `${test.status} ` : ''; + + // TODO(post-preview): Re-enable duration display once configurable duration is launched. + const durationText = ''; + + // Column width for side-by-side variants + const colW = 28; + + return ( + + + {/* ── Header: Line 1 — status ─────────────────────────── */} + + + {statusPrefix && {statusPrefix}} + {`● ${test.executionStatus}`} + + {durationText && {durationText}} + + + {/* ── Header: Line 2 — invocation URL ────────────────────── */} + {targetName ? ( + + {`Invocation URL: ${gatewayUrlFromArn(test.gatewayArn)}/${targetName}/invocations`} + + ) : ( + + Invocation URL: loading... + + )} + + {/* ── Header: Line 3 — online eval (only for single-config mode) ── */} + {'onlineEvaluationConfigArn' in test.evaluationConfig && ( + + {`Online Eval: ${extractId(test.evaluationConfig.onlineEvaluationConfigArn)}`} + + )} + + {/* ── Description (if present) ────────────────────────── */} + {test.description && ( + + {`Description: ${test.description}`} + + )} + + {/* ── Variants: side-by-side ──────────────────────────── */} + + + {'CONTROL (C)'} + {`${String(controlVariant?.weight ?? 'N/A')}% traffic`} + + {controlVariant?.variantConfiguration.target + ? `target: ${controlVariant.variantConfiguration.target.name}` + : `${extractId(controlVariant?.variantConfiguration.configurationBundle?.bundleArn ?? '')} @ ${shortVersion(controlVariant?.variantConfiguration.configurationBundle?.bundleVersion ?? '')}`} + + + + {'TREATMENT (T1)'} + {`${String(treatmentVariant?.weight ?? 'N/A')}% traffic`} + + {treatmentVariant?.variantConfiguration.target + ? `target: ${treatmentVariant.variantConfiguration.target.name}` + : `${extractId(treatmentVariant?.variantConfiguration.configurationBundle?.bundleArn ?? '')} @ ${shortVersion(treatmentVariant?.variantConfiguration.configurationBundle?.bundleVersion ?? '')}`} + + + + + {/* ── Evaluation Results ───────────────────────────────── */} + + {test.results ? ( + <> + + {rule( + 'Results', + test.results.analysisTimestamp ? formatTimestamp(test.results.analysisTimestamp) : undefined + )} + + + + {''} + + + {'Control'} + + + {'Treatment'} + + {'Δ'} + + {test.results.evaluatorMetrics.map((metric, i) => ( + 0 ? 1 : 0}> + + + {extractId(metric.evaluatorArn)} + + + {metric.controlStats.mean.toFixed(4)} + + + {metric.variantResults[0]?.mean.toFixed(4) ?? ''} + + {metric.variantResults[0]?.isSignificant ? ( + {`+${(metric.variantResults[0]?.percentChange ?? 0).toFixed(2)}% ✓`} + ) : ( + {`${(metric.variantResults[0]?.percentChange ?? 0).toFixed(2)}% ✗`} + )} + + + + {''} + + + {`n=${metric.controlStats.sampleSize}`} + + + {`n=${metric.variantResults[0]?.sampleSize ?? ''}`} + + {`p=${metric.variantResults[0]?.pValue?.toFixed(3) ?? 'N/A'}`} + + + ))} + + ) : ( + <> + {rule('Results')} + + No evaluation results yet. + + + )} + + + {/* ── Debug Panel ─────────────────────────────────────── */} + {debugLoading && ( + + + + )} + {debugResults && ( + + {rule('Pipeline Debug')} + {debugResults.map((check, i) => { + const icon = check.status === 'pass' ? '✓' : check.status === 'fail' ? '✗' : '⚠'; + const color = check.status === 'pass' ? 'green' : check.status === 'fail' ? 'red' : 'yellow'; + return ( + + {` ${icon} `} + {check.label} + {` ${check.detail}`} + + ); + })} + + )} + + {/* ── Stop confirmation ────────────────────────────────── */} + {confirmingStop && ( + + + { + 'Stop this AB test permanently? All traffic will shift to the control variant. This cannot be undone. (Y/n)' + } + + + )} + + {/* ── Promote confirmation ─────────────────────────────── */} + {confirmingPromote && ( + + + { + 'Promote treatment as winner? This will stop the AB test and update the control endpoint to the treatment version. Run `agentcore deploy` after to apply. (Y/n)' + } + + + )} + + {/* ── Action feedback ──────────────────────────────────── */} + {actionMessage && !confirmingStop && ( + + {actionMessage} + + )} + + + ); +} diff --git a/src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx b/src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx new file mode 100644 index 000000000..9d47e4441 --- /dev/null +++ b/src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx @@ -0,0 +1,90 @@ +import { ConfigIO } from '../../../../lib'; +import type { SelectableItem } from '../../components'; +import { Screen, SelectScreen } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { ABTestDetailScreen } from './ABTestDetailScreen'; +import { Text } from 'ink'; +import React, { useEffect, useRef, useState } from 'react'; + +interface ABTestPickerScreenProps { + onExit: () => void; +} + +interface DeployedABTest { + name: string; + abTestId: string; +} + +export function ABTestPickerScreen({ onExit }: ABTestPickerScreenProps) { + const [tests, setTests] = useState(null); + const [selectedTest, setSelectedTest] = useState(null); + const [region, setRegion] = useState('us-east-1'); + + const hasFetched = useRef(false); + useEffect(() => { + if (hasFetched.current) return; + hasFetched.current = true; + const load = async () => { + try { + const configIO = new ConfigIO(); + const [deployedState, targets] = await Promise.all([ + configIO.readDeployedState(), + configIO.resolveAWSDeploymentTargets(), + ]); + const found: DeployedABTest[] = []; + for (const target of Object.values(deployedState.targets ?? {})) { + const abTests = target.resources?.abTests; + if (abTests) { + for (const [name, state] of Object.entries(abTests)) { + found.push({ name, abTestId: state.abTestId }); + } + } + } + setTests(found); + if (targets.length > 0) setRegion(targets[0]!.region); + } catch { + setTests([]); + } + }; + void load(); + }, []); + + if (selectedTest) { + return setSelectedTest(null)} />; + } + + if (tests === null) { + return ( + + Loading AB tests... + + ); + } + + if (tests.length === 0) { + return ( + + No deployed AB tests found. + Add one with `agentcore add ab-test` and deploy. + + ); + } + + const items: SelectableItem[] = tests.map(t => ({ + id: t.name, + title: t.name, + description: `ID: ${t.abTestId}`, + })); + + return ( + { + const test = tests.find(t => t.name === item.id); + if (test) setSelectedTest(test); + }} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/ab-test/AddABTestFlow.tsx b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx new file mode 100644 index 000000000..b8313075d --- /dev/null +++ b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx @@ -0,0 +1,281 @@ +import { ConfigIO } from '../../../../lib'; +import { listConfigurationBundleVersions } from '../../../aws/agentcore-config-bundles'; +import { ErrorPrompt } from '../../components'; +import { useCreateABTest, useExistingABTestNames } from '../../hooks/useCreateABTest'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddConfigBundleFlow } from '../config-bundle/AddConfigBundleFlow'; +import { AddABTestScreen } from './AddABTestScreen'; +import type { HttpGatewayInfo, OnlineEvalConfigInfo, RuntimeInfo } from './AddABTestScreen'; +import { TargetBasedABTestScreen } from './TargetBasedABTestScreen'; +import type { AddABTestConfig } from './types'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'create-wizard' } + | { name: 'target-wizard' } + | { name: 'create-bundle' } + | { name: 'create-success'; testName: string } + | { name: 'error'; message: string }; + +interface AddABTestFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onDeploy }: AddABTestFlowProps) { + const { createABTest, createTargetBasedABTest, reset: resetCreate } = useCreateABTest(); + const { names: existingNames } = useExistingABTestNames(); + const [flow, setFlow] = useState({ name: 'create-wizard' }); + + // Load deployed state for bundle lists + const [agents, setAgents] = useState<{ name: string }[]>([]); + const [existingHttpGateways, setExistingHttpGateways] = useState([]); + const [deployedBundles, setDeployedBundles] = useState<{ name: string; bundleId: string }[]>([]); + const [onlineEvalConfigs, setOnlineEvalConfigs] = useState([]); + const [runtimesInfo, setRuntimesInfo] = useState([]); + const [httpGatewayDetails, setHttpGatewayDetails] = useState([]); + const [onlineEvalConfigDetails, setOnlineEvalConfigDetails] = useState([]); + const [region, setRegion] = useState('us-east-1'); + + const [loadEpoch, setLoadEpoch] = useState(0); + + useEffect(() => { + void (async () => { + try { + const configIO = new ConfigIO(); + const deployedState = await configIO.readDeployedState(); + const projectSpec = await configIO.readProjectSpec(); + + // Get region from first target + for (const [, target] of Object.entries(deployedState.targets ?? {})) { + const resources = target.resources; + + // Deployed config bundles + const bundles = resources?.configBundles; + if (bundles) { + setDeployedBundles( + Object.entries(bundles).map(([name, state]) => ({ + name, + bundleId: state.bundleId, + })) + ); + } + break; + } + + // Agents from project spec runtimes + const runtimes = projectSpec.runtimes ?? []; + setAgents(runtimes.map(r => ({ name: r.name }))); + + // Runtimes with endpoints for target-based mode + setRuntimesInfo( + runtimes.map(r => ({ + name: r.name, + endpoints: Object.entries(r.endpoints ?? {}).map(([epName, ep]) => ({ + name: epName, + version: ep.version, + })), + })) + ); + + // Existing HTTP gateways from project spec + const httpGws = projectSpec.httpGateways ?? []; + setExistingHttpGateways(httpGws.map(gw => gw.name)); + + // HTTP gateway details with targets for target-based mode + setHttpGatewayDetails( + httpGws.map(gw => ({ + name: gw.name, + runtimeRef: gw.runtimeRef, + targets: (gw.targets ?? []).map(t => ({ + name: t.name, + runtimeRef: t.runtimeRef, + qualifier: t.qualifier, + })), + })) + ); + + // Online eval configs from project spec + const evalConfigs = projectSpec.onlineEvalConfigs ?? []; + setOnlineEvalConfigs(evalConfigs.map(c => c.name)); + setOnlineEvalConfigDetails( + evalConfigs.map(c => ({ + name: c.name, + agent: c.agent, + endpoint: c.endpoint, + })) + ); + + // Region from aws-targets, falling back to env + const targets = await configIO.resolveAWSDeploymentTargets(); + if (targets.length > 0) { + setRegion(targets[0]!.region); + } else { + setRegion(process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'); + } + } catch { + // No deployed state — lists will be empty + } + })(); + }, [loadEpoch]); + + const fetchBundleVersions = useCallback( + async (bundleId: string) => { + try { + const result = await listConfigurationBundleVersions({ region, bundleId }); + return result.versions.map(v => ({ + versionId: v.versionId, + createdAt: v.versionCreatedAt, + })); + } catch { + return []; + } + }, + [region] + ); + + useEffect(() => { + if (!isInteractive && flow.name === 'create-success') { + onExit(); + } + }, [isInteractive, flow.name, onExit]); + + const handleCreateComplete = useCallback( + (config: AddABTestConfig) => { + if (config.mode === 'target-based') { + const gatewayName = + config.gatewayChoice.type === 'existing-http' + ? config.gatewayChoice.name + : config.gatewayChoice.type === 'create-new' + ? `${config.name.replace(/_/g, '-').slice(0, 44)}-gw` + : ''; + void createTargetBasedABTest({ + name: config.name, + description: config.description || undefined, + gateway: gatewayName, + runtime: config.runtime, + controlEndpoint: config.controlEndpoint, + treatmentEndpoint: config.treatmentEndpoint, + controlWeight: config.controlWeight, + treatmentWeight: config.treatmentWeight, + controlOnlineEval: config.controlOnlineEval, + treatmentOnlineEval: config.treatmentOnlineEval, + enableOnCreate: config.enableOnCreate, + }).then(result => { + if (result.ok) { + setFlow({ name: 'create-success', testName: result.testName }); + return; + } + setFlow({ name: 'error', message: result.error }); + }); + return; + } + + const controlWeight = 100 - config.treatmentWeight; + void createABTest({ + name: config.name, + description: config.description || undefined, + agent: config.agent, + gatewayChoice: config.gatewayChoice, + controlBundle: config.controlBundle, + controlVersion: config.controlVersion, + treatmentBundle: config.treatmentBundle, + treatmentVersion: config.treatmentVersion, + controlWeight, + treatmentWeight: config.treatmentWeight, + onlineEval: config.onlineEval, + maxDuration: config.maxDuration, + enableOnCreate: config.enableOnCreate, + }).then(result => { + if (result.ok) { + setFlow({ name: 'create-success', testName: result.testName }); + return; + } + setFlow({ name: 'error', message: result.error }); + }); + }, + [createABTest, createTargetBasedABTest] + ); + + const handleSwitchToTargetBased = useCallback(() => { + setFlow({ name: 'target-wizard' }); + }, []); + + const handleCreateBundle = useCallback(() => { + setFlow({ name: 'create-bundle' }); + }, []); + + const handleBundleFlowDone = useCallback(() => { + setLoadEpoch(e => e + 1); + setFlow({ name: 'create-wizard' }); + }, []); + + if (flow.name === 'create-bundle') { + return ( + + ); + } + + if (flow.name === 'target-wizard') { + return ( + + ); + } + + if (flow.name === 'create-wizard') { + return ( + + ); + } + + if (flow.name === 'create-success') { + return ( + + ); + } + + return ( + { + resetCreate(); + setFlow({ name: 'create-wizard' }); + }} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/ab-test/AddABTestScreen.tsx b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx new file mode 100644 index 000000000..3306ce86c --- /dev/null +++ b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx @@ -0,0 +1,914 @@ +import { ABTestNameSchema } from '../../../../schema/schemas/primitives/ab-test'; +import type { SelectableItem } from '../../components'; +import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import type { VersionLoadState } from './VariantConfigForm'; +import { VariantConfigForm } from './VariantConfigForm'; +import type { AddABTestConfig, TargetInfo } from './types'; +import { AB_TEST_STEP_LABELS } from './types'; +import { useAddABTestWizard } from './useAddABTestWizard'; +import { Box, Text } from 'ink'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +function formatVersionDate(value: string): string { + const n = Number(value); + if (!isNaN(n) && n > 0) { + // Epoch seconds (< 1e12) vs milliseconds (>= 1e12) + const ms = n < 1e12 ? n * 1000 : n; + return new Date(ms).toLocaleString(); + } + return new Date(value).toLocaleString(); +} + +/** Runtime endpoint info passed from the parent flow. */ +export interface RuntimeEndpointInfo { + name: string; + version: number; +} + +/** Runtime info with endpoints, passed from the parent flow. */ +export interface RuntimeInfo { + name: string; + endpoints: RuntimeEndpointInfo[]; +} + +/** Gateway target info passed from the parent flow. */ +export interface GatewayTargetInfo { + name: string; + runtimeRef: string; + qualifier: string; +} + +/** HTTP gateway info with targets, passed from the parent flow. */ +export interface HttpGatewayInfo { + name: string; + runtimeRef: string; + targets: GatewayTargetInfo[]; +} + +/** Online eval config info with agent and endpoint for filtering. */ +export interface OnlineEvalConfigInfo { + name: string; + agent: string; + endpoint?: string; +} + +interface AddABTestScreenProps { + onComplete: (config: AddABTestConfig) => void; + onExit: () => void; + existingTestNames: string[]; + agents: { name: string }[]; + existingHttpGateways: string[]; + deployedBundles: { name: string; bundleId: string }[]; + onlineEvalConfigs: string[]; + fetchBundleVersions: (bundleId: string) => Promise<{ versionId: string; createdAt: string }[]>; + onCreateBundle?: () => void; + /** Full runtime info including endpoints (for target-based mode). */ + runtimes: RuntimeInfo[]; + /** Full HTTP gateway info including targets (for target-based mode). */ + httpGatewayDetails: HttpGatewayInfo[]; + /** Full online eval config objects for target-based eval filtering. */ + onlineEvalConfigDetails?: OnlineEvalConfigInfo[]; + /** Callback to switch to the dedicated target-based wizard screen. */ + onSwitchToTargetBased?: () => void; +} + +export function AddABTestScreen({ + onComplete, + onExit, + existingTestNames, + agents, + existingHttpGateways, + deployedBundles, + onlineEvalConfigs, + fetchBundleVersions, + onCreateBundle, + runtimes, + httpGatewayDetails, + onlineEvalConfigDetails = [], + onSwitchToTargetBased, +}: AddABTestScreenProps) { + const wizard = useAddABTestWizard(); + + // Build select items + const agentItems: SelectableItem[] = useMemo( + () => agents.map(a => ({ id: a.name, title: a.name, description: 'Agent' })), + [agents] + ); + + const bundleItems: SelectableItem[] = useMemo( + () => deployedBundles.map(b => ({ id: b.name, title: b.name, description: `ID: ${b.bundleId}` })), + [deployedBundles] + ); + + const onlineEvalItems: SelectableItem[] = useMemo( + () => onlineEvalConfigs.map(name => ({ id: name, title: name, description: 'Online Eval Config' })), + [onlineEvalConfigs] + ); + + const gatewayItems: SelectableItem[] = useMemo(() => { + const items: SelectableItem[] = []; + for (const gwName of existingHttpGateways) { + items.push({ id: gwName, title: gwName, description: 'Existing HTTP gateway' }); + } + items.push({ + id: '__create__', + title: '+ Create new gateway', + description: 'Auto-create for this AB test', + spaceBefore: items.length > 0, + }); + return items; + }, [existingHttpGateways]); + + const enableItems: SelectableItem[] = useMemo( + () => [ + { id: 'yes', title: 'Yes', description: 'Start the AB test immediately after deploy' }, + { id: 'no', title: 'No', description: 'Create paused — start manually later' }, + ], + [] + ); + + // Version items — fetched dynamically per bundle + const [controlVersionItems, setControlVersionItems] = React.useState([]); + const [treatmentVersionItems, setTreatmentVersionItems] = React.useState([]); + const [controlVersionLoadState, setControlVersionLoadState] = React.useState('idle'); + const [treatmentVersionLoadState, setTreatmentVersionLoadState] = React.useState('idle'); + + const handleFetchVersions = React.useCallback( + (bundleName: string) => { + const bundle = deployedBundles.find(b => b.name === bundleName); + if (!bundle) return; + + setControlVersionLoadState('loading'); + setTreatmentVersionLoadState('loading'); + + void fetchBundleVersions(bundle.bundleId) + .then(versions => { + const items = versions.map(v => ({ + id: v.versionId, + title: v.versionId.slice(0, 8), + description: `Created: ${formatVersionDate(v.createdAt)}`, + })); + setControlVersionItems(items); + setTreatmentVersionItems(items); + setControlVersionLoadState('loaded'); + setTreatmentVersionLoadState('loaded'); + }) + .catch(() => { + setControlVersionLoadState('error'); + setTreatmentVersionLoadState('error'); + }); + }, + [deployedBundles, fetchBundleVersions] + ); + + // ── Gateway sub-flow state (target-based: "create new" text input) ──────── + const [gatewayCreateMode, setGatewayCreateMode] = useState(false); + + // ── Target picker sub-flow state ────────────────────────────────────────── + // Sub-flow phases: 'pick' -> 'selectRuntime' -> 'selectQualifier' + type TargetSubFlowPhase = 'pick' | 'selectRuntime' | 'selectQualifier'; + const [controlSubFlow, setControlSubFlow] = useState('pick'); + const [controlNewRuntime, setControlNewRuntime] = useState(''); + + const [treatmentSubFlow, setTreatmentSubFlow] = useState('pick'); + const [treatmentNewRuntime, setTreatmentNewRuntime] = useState(''); + + /* eslint-disable react-hooks/set-state-in-effect -- intentional reset on step change */ + useEffect(() => { + if (wizard.step === 'controlTarget') { + setControlSubFlow('pick'); + setControlNewRuntime(''); + } + }, [wizard.step]); + + useEffect(() => { + if (wizard.step === 'treatmentTarget') { + setTreatmentSubFlow('pick'); + setTreatmentNewRuntime(''); + } + }, [wizard.step]); + /* eslint-enable react-hooks/set-state-in-effect */ + + // Step flags + const isModeStep = wizard.step === 'mode'; + const isNameStep = wizard.step === 'name'; + const isDescriptionStep = wizard.step === 'description'; + const isAgentStep = wizard.step === 'agent'; + const isGatewayStep = wizard.step === 'gateway'; + const isVariantsStep = wizard.step === 'variants'; + const isOnlineEvalStep = wizard.step === 'onlineEval'; + const isControlTargetStep = wizard.step === 'controlTarget'; + const isTreatmentTargetStep = wizard.step === 'treatmentTarget'; + const isWeightsStep = wizard.step === 'weights'; + const isEvalPathStep = wizard.step === 'evalPath'; + const isEvalSelectStep = wizard.step === 'evalSelect'; + const isEnableStep = wizard.step === 'enableOnCreate'; + const isConfirmStep = wizard.step === 'confirm'; + + const isTargetBased = wizard.config.mode === 'target-based'; + + // Tell the wizard which steps to skip (both forward and backward navigation). + const gatewayChoiceTypeRef = React.useRef(wizard.config.gatewayChoice.type); + + const shouldSkipStep = useCallback( + (s: string) => { + // Agent selection is only needed in config-bundle mode when auto-creating a gateway. + if (s === 'agent' && (isTargetBased || gatewayChoiceTypeRef.current !== 'create-new')) return true; + // Config-bundle steps skipped in target-based mode + if (s === 'variants' && isTargetBased) return true; + if (s === 'onlineEval' && isTargetBased) return true; + // Target-based steps skipped in config-bundle mode + if (s === 'controlTarget' && !isTargetBased) return true; + if (s === 'treatmentTarget' && !isTargetBased) return true; + if (s === 'weights' && !isTargetBased) return true; + if (s === 'evalPath' && !isTargetBased) return true; + if (s === 'evalSelect' && !isTargetBased) return true; + if (s === 'evalCreate' && !isTargetBased) return true; + if (s === 'evalSamplingRate' && !isTargetBased) return true; + if (s === 'maxDuration') return true; + return false; + }, + [isTargetBased] + ); + + useEffect(() => { + wizard.setSkipCheck(shouldSkipStep); + }, [shouldSkipStep]); // wizard.setSkipCheck is stable (useCallback with no deps) + + // Mode selection items + const modeItems: SelectableItem[] = useMemo( + () => [ + { + id: 'config-bundle', + title: 'Config Bundle', + description: 'Split traffic between config bundle versions (same target, different config)', + }, + { + id: 'target-based', + title: 'Target-Based', + description: 'Split traffic between gateway targets (different targets, each self-contained)', + }, + ], + [] + ); + + // ── Target picker items builder ────────────────────────────────────────── + // Builds the three-section grouped picker items for target selection. + const buildTargetItems = useCallback( + (excludeTarget: TargetInfo | null): SelectableItem[] => { + const items: SelectableItem[] = []; + + // Section 1: Existing targets on the selected gateway + const selectedGw = httpGatewayDetails.find(g => g.name === wizard.config.gateway); + const existingTargets = selectedGw?.targets ?? []; + if (existingTargets.length > 0) { + items.push({ + id: '__section_existing__', + title: '── Existing Targets ──', + description: '', + disabled: true, + }); + for (const t of existingTargets) { + if (excludeTarget?.name === t.name) continue; + items.push({ + id: `existing:${t.name}`, + title: t.name, + description: `endpoint=${t.qualifier} runtime=${t.runtimeRef}`, + }); + } + } + + // Section 2: Endpoints from project runtimes (quick-create targets) + const endpointItems: SelectableItem[] = []; + for (const rt of runtimes) { + for (const ep of rt.endpoints) { + const targetName = ep.name; + if (excludeTarget?.name === targetName) continue; + endpointItems.push({ + id: `endpoint:${rt.name}/${ep.name}`, + title: `${rt.name}/${ep.name}`, + description: `v${ep.version}`, + }); + } + } + if (endpointItems.length > 0) { + items.push({ + id: '__section_endpoints__', + title: '── Endpoints ──', + description: 'Select to auto-create target', + disabled: true, + spaceBefore: items.length > 0, + }); + items.push(...endpointItems); + } + + // Section 3: Create new target + items.push({ + id: '__create_target__', + title: '+ Create new target', + description: 'Configure runtime, name, and endpoint', + spaceBefore: true, + }); + + return items; + }, + [httpGatewayDetails, runtimes, wizard.config.gateway] + ); + + const controlTargetItems = useMemo(() => buildTargetItems(null), [buildTargetItems]); + const treatmentTargetItems = useMemo( + () => buildTargetItems(wizard.config.controlTargetInfo), + [buildTargetItems, wizard.config.controlTargetInfo] + ); + + // Runtime items for the "create new target" sub-flow + const runtimeItems: SelectableItem[] = useMemo( + () => runtimes.map(r => ({ id: r.name, title: r.name, description: `${r.endpoints.length} endpoint(s)` })), + [runtimes] + ); + + // Qualifier items for a given runtime (DEFAULT + all endpoints) + const buildQualifierItems = useCallback( + (runtimeName: string): SelectableItem[] => { + const rt = runtimes.find(r => r.name === runtimeName); + const items: SelectableItem[] = [{ id: 'DEFAULT', title: 'DEFAULT', description: 'Default endpoint' }]; + if (rt) { + for (const ep of rt.endpoints) { + items.push({ id: ep.name, title: ep.name, description: `v${ep.version}` }); + } + } + return items; + }, + [runtimes] + ); + + const controlEndpointItems = useMemo( + () => buildQualifierItems(controlNewRuntime), + [buildQualifierItems, controlNewRuntime] + ); + const treatmentEndpointItems = useMemo( + () => buildQualifierItems(treatmentNewRuntime), + [buildQualifierItems, treatmentNewRuntime] + ); + + // Navigation hooks for select steps + const modeNav = useListNavigation({ + items: modeItems, + onSelect: item => { + if (item.id === 'target-based' && onSwitchToTargetBased) { + onSwitchToTargetBased(); + return; + } + wizard.setMode(item.id as 'config-bundle' | 'target-based'); + }, + onExit: () => wizard.goBack(), + isActive: isModeStep, + }); + + const agentNav = useListNavigation({ + items: agentItems, + onSelect: item => wizard.setAgent(item.id), + onExit: () => wizard.goBack(), + isActive: isAgentStep, + }); + + const gatewayNav = useListNavigation({ + items: gatewayItems, + onSelect: item => { + if (item.id === '__create__') { + setGatewayCreateMode(true); + return; + } + const choice = { type: 'existing-http', name: item.id } as const; + gatewayChoiceTypeRef.current = choice.type; + wizard.setGatewayWithName(item.id, false); + }, + onExit: () => wizard.goBack(), + isActive: isGatewayStep && !gatewayCreateMode, + isDisabled: item => item.disabled === true, + }); + + const onlineEvalNav = useListNavigation({ + items: onlineEvalItems, + onSelect: item => wizard.setOnlineEval(item.id), + onExit: () => wizard.goBack(), + isActive: isOnlineEvalStep, + }); + + // ── Control target picker navigation ───────────────────────────────────── + const controlTargetNav = useListNavigation({ + items: controlTargetItems, + onSelect: item => { + if (item.id === '__create_target__') { + setControlSubFlow('selectRuntime'); + return; + } + if (item.id.startsWith('existing:')) { + const targetName = item.id.replace('existing:', ''); + const selectedGw = httpGatewayDetails.find(g => g.name === wizard.config.gateway); + const target = selectedGw?.targets.find(t => t.name === targetName); + if (target) { + wizard.setControlTarget( + { name: target.name, runtimeRef: target.runtimeRef, qualifier: target.qualifier }, + false + ); + } + return; + } + if (item.id.startsWith('endpoint:')) { + const path = item.id.replace('endpoint:', ''); + const [runtimeName, endpointName] = path.split('/'); + if (runtimeName && endpointName) { + const autoName = `${runtimeName}-${endpointName}`; + wizard.setControlTarget({ name: autoName, runtimeRef: runtimeName, qualifier: endpointName }, true); + } + } + }, + onExit: () => wizard.goBack(), + isActive: isControlTargetStep && controlSubFlow === 'pick', + isDisabled: item => item.disabled === true, + }); + + // Control sub-flow: select runtime + const controlRuntimeNav = useListNavigation({ + items: runtimeItems, + onSelect: item => { + setControlNewRuntime(item.id); + setControlSubFlow('selectQualifier'); + }, + onExit: () => setControlSubFlow('pick'), + isActive: isControlTargetStep && controlSubFlow === 'selectRuntime', + }); + + // Control sub-flow: select qualifier (auto-generates target name) + const controlEndpointNav = useListNavigation({ + items: controlEndpointItems, + onSelect: item => { + const autoName = `${controlNewRuntime}-${item.id}`; + wizard.setControlTarget({ name: autoName, runtimeRef: controlNewRuntime, qualifier: item.id }, true); + }, + onExit: () => setControlSubFlow('selectRuntime'), + isActive: isControlTargetStep && controlSubFlow === 'selectQualifier', + }); + + // ── Treatment target picker navigation ─────────────────────────────────── + const treatmentTargetNav = useListNavigation({ + items: treatmentTargetItems, + onSelect: item => { + if (item.id === '__create_target__') { + setTreatmentSubFlow('selectRuntime'); + return; + } + if (item.id.startsWith('existing:')) { + const targetName = item.id.replace('existing:', ''); + const selectedGw = httpGatewayDetails.find(g => g.name === wizard.config.gateway); + const target = selectedGw?.targets.find(t => t.name === targetName); + if (target) { + wizard.setTreatmentTarget( + { name: target.name, runtimeRef: target.runtimeRef, qualifier: target.qualifier }, + false + ); + } + return; + } + if (item.id.startsWith('endpoint:')) { + const path = item.id.replace('endpoint:', ''); + const [runtimeName, endpointName] = path.split('/'); + if (runtimeName && endpointName) { + const autoName = `${runtimeName}-${endpointName}`; + wizard.setTreatmentTarget({ name: autoName, runtimeRef: runtimeName, qualifier: endpointName }, true); + } + } + }, + onExit: () => wizard.goBack(), + isActive: isTreatmentTargetStep && treatmentSubFlow === 'pick', + isDisabled: item => item.disabled === true, + }); + + // Treatment sub-flow: select runtime + const treatmentRuntimeNav = useListNavigation({ + items: runtimeItems, + onSelect: item => { + setTreatmentNewRuntime(item.id); + setTreatmentSubFlow('selectQualifier'); + }, + onExit: () => setTreatmentSubFlow('pick'), + isActive: isTreatmentTargetStep && treatmentSubFlow === 'selectRuntime', + }); + + // Treatment sub-flow: select qualifier (auto-generates target name) + const treatmentEndpointNav = useListNavigation({ + items: treatmentEndpointItems, + onSelect: item => { + const autoName = `${treatmentNewRuntime}-${item.id}`; + wizard.setTreatmentTarget({ name: autoName, runtimeRef: treatmentNewRuntime, qualifier: item.id }, true); + }, + onExit: () => setTreatmentSubFlow('selectRuntime'), + isActive: isTreatmentTargetStep && treatmentSubFlow === 'selectQualifier', + }); + + const evalPathItems: SelectableItem[] = useMemo( + () => [ + { + id: 'select', + title: 'Select existing online eval configs', + description: 'Use configs already in your project', + }, + { id: 'create', title: 'Create new', description: 'Pick evaluators + sampling rate, auto-create configs' }, + ], + [] + ); + + const evalPathNav = useListNavigation({ + items: evalPathItems, + onSelect: item => wizard.setEvalPath(item.id as 'select' | 'create'), + onExit: () => wizard.goBack(), + isActive: isEvalPathStep, + }); + + // ── Eval select sub-flow: pick control eval, then treatment eval ──────── + type EvalSelectPhase = 'controlEval' | 'treatmentEval'; + const [evalSelectPhase, setEvalSelectPhase] = useState('controlEval'); + const [selectedControlEval, setSelectedControlEval] = useState(''); + + // Reset eval select sub-flow when entering the step + /* eslint-disable react-hooks/set-state-in-effect -- intentional reset on step change */ + useEffect(() => { + if (wizard.step === 'evalSelect') { + setEvalSelectPhase('controlEval'); + setSelectedControlEval(''); + } + }, [wizard.step]); + /* eslint-enable react-hooks/set-state-in-effect */ + + // Filter online eval configs by runtime + endpoint (qualifier) + const controlRuntime = wizard.config.controlTargetInfo?.runtimeRef ?? ''; + const controlEndpoint = wizard.config.controlTargetInfo?.qualifier ?? ''; + const treatmentRuntime = wizard.config.treatmentTargetInfo?.runtimeRef ?? ''; + const treatmentEndpoint = wizard.config.treatmentTargetInfo?.qualifier ?? ''; + + const controlEvalItems: SelectableItem[] = useMemo(() => { + return onlineEvalConfigDetails + .filter(c => c.agent === controlRuntime && (c.endpoint ?? 'DEFAULT') === controlEndpoint) + .map(c => ({ id: c.name, title: c.name, description: `${c.agent}/${c.endpoint ?? 'DEFAULT'}` })); + }, [onlineEvalConfigDetails, controlRuntime, controlEndpoint]); + + const treatmentEvalItems: SelectableItem[] = useMemo(() => { + return onlineEvalConfigDetails + .filter(c => c.agent === treatmentRuntime && (c.endpoint ?? 'DEFAULT') === treatmentEndpoint) + .map(c => ({ id: c.name, title: c.name, description: `${c.agent}/${c.endpoint ?? 'DEFAULT'}` })); + }, [onlineEvalConfigDetails, treatmentRuntime, treatmentEndpoint]); + + const controlEvalNoMatch = isEvalSelectStep && evalSelectPhase === 'controlEval' && controlEvalItems.length === 0; + const treatmentEvalNoMatch = + isEvalSelectStep && evalSelectPhase === 'treatmentEval' && treatmentEvalItems.length === 0; + + const controlEvalNav = useListNavigation({ + items: controlEvalItems, + onSelect: item => { + setSelectedControlEval(item.id); + setEvalSelectPhase('treatmentEval'); + }, + onExit: () => wizard.goBack(), + isActive: isEvalSelectStep && evalSelectPhase === 'controlEval' && !controlEvalNoMatch, + }); + + const treatmentEvalNav = useListNavigation({ + items: treatmentEvalItems, + onSelect: item => { + wizard.setEvalSelect(selectedControlEval, item.id); + }, + onExit: () => setEvalSelectPhase('controlEval'), + isActive: isEvalSelectStep && evalSelectPhase === 'treatmentEval' && !treatmentEvalNoMatch, + }); + + const enableNav = useListNavigation({ + items: enableItems, + onSelect: item => wizard.setEnableOnCreate(item.id === 'yes'), + onExit: () => wizard.goBack(), + isActive: isEnableStep, + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(wizard.config), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + // Help text + const isSelectStep = + isModeStep || + isAgentStep || + (isGatewayStep && !gatewayCreateMode) || + isOnlineEvalStep || + isEnableStep || + isControlTargetStep || + isTreatmentTargetStep || + isEvalPathStep || + isEvalSelectStep; + const helpText = isSelectStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : isVariantsStep + ? HELP_TEXT.VARIANTS_FORM + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ; + + const controlWeight = 100 - wizard.config.treatmentWeight; + + // Format target display for confirm review + const formatTargetDisplay = (info: TargetInfo | null, isNew: boolean): string => { + if (!info) return '(not set)'; + const newLabel = isNew ? ' (new)' : ''; + return `${info.name} endpoint=${info.qualifier} runtime=${info.runtimeRef}${newLabel}`; + }; + + return ( + + + {isModeStep && ( + + )} + + {isNameStep && ( + (existingTestNames.includes(value) ? `AB test "${value}" already exists` : true)} + /> + )} + + {isDescriptionStep && ( + wizard.goBack()} + /> + )} + + {isAgentStep && } + + {/* ── Step 4: Gateway selection ──────────────────────────── */} + {isGatewayStep && !gatewayCreateMode && ( + + )} + {isGatewayStep && gatewayCreateMode && ( + { + gatewayChoiceTypeRef.current = 'create-new'; + wizard.setGatewayWithName(name, true); + setGatewayCreateMode(false); + }} + onCancel={() => setGatewayCreateMode(false)} + /> + )} + + {isVariantsStep && ( + wizard.goBack()} + onCreateBundle={onCreateBundle} + /> + )} + + {/* ── Step 5: Control target selection ─────────────────── */} + {isControlTargetStep && controlSubFlow === 'pick' && ( + + )} + {isControlTargetStep && controlSubFlow === 'selectRuntime' && ( + + )} + {isControlTargetStep && controlSubFlow === 'selectQualifier' && ( + + )} + + {/* ── Step 6: Treatment target selection ───────────────── */} + {isTreatmentTargetStep && treatmentSubFlow === 'pick' && ( + + {wizard.config.controlTargetInfo && ( + + + {'\u2713'} Control: {wizard.config.controlTargetInfo.name} endpoint= + {wizard.config.controlTargetInfo.qualifier} + + + )} + + + )} + {isTreatmentTargetStep && treatmentSubFlow === 'selectRuntime' && ( + + )} + {isTreatmentTargetStep && treatmentSubFlow === 'selectQualifier' && ( + + )} + + {/* ── Target-based: Traffic weights ───────────────────── */} + {isWeightsStep && ( + { + const w = parseInt(value, 10); + if (!isNaN(w) && w >= 1 && w <= 99) { + wizard.setWeights(w, 100 - w); + } + }} + onCancel={() => wizard.goBack()} + customValidation={value => { + const w = parseInt(value, 10); + if (isNaN(w)) return 'Must be a number'; + if (w < 1 || w > 99) return 'Must be between 1 and 99'; + return true; + }} + /> + )} + + {/* ── Target-based: Eval path selection ───────────────── */} + {isEvalPathStep && ( + + )} + + {/* ── Target-based: Eval select (control) ───────────── */} + {isEvalSelectStep && evalSelectPhase === 'controlEval' && !controlEvalNoMatch && ( + + )} + {isEvalSelectStep && evalSelectPhase === 'controlEval' && controlEvalNoMatch && ( + + No online eval config found for {controlRuntime}/{controlEndpoint}. Create one first: agentcore add + online-eval --runtime {controlRuntime} --endpoint {controlEndpoint} + + )} + + {/* ── Target-based: Eval select (treatment) ─────────── */} + {isEvalSelectStep && evalSelectPhase === 'treatmentEval' && !treatmentEvalNoMatch && ( + + + + {'\u2713'} Control eval: {selectedControlEval} + + + + + )} + {isEvalSelectStep && evalSelectPhase === 'treatmentEval' && treatmentEvalNoMatch && ( + + No online eval config found for {treatmentRuntime}/{treatmentEndpoint}. Create one first: agentcore add + online-eval --runtime {treatmentRuntime} --endpoint {treatmentEndpoint} + + )} + + {/* ── Config-bundle: Online eval selection ────────────── */} + {isOnlineEvalStep && + (onlineEvalItems.length > 0 ? ( + + ) : ( + + No online eval configs found. An online eval is required for AB tests. Add one with `agentcore add + online-eval`, then retry. Press Esc to go back. + + ))} + + {/* TODO(post-preview): Re-enable maxDuration TextInput once configurable duration is launched. */} + + {isEnableStep && ( + + )} + + {isConfirmStep && ( + + )} + + + ); +} diff --git a/src/cli/tui/screens/ab-test/RemoveABTestScreen.tsx b/src/cli/tui/screens/ab-test/RemoveABTestScreen.tsx new file mode 100644 index 000000000..48adc621f --- /dev/null +++ b/src/cli/tui/screens/ab-test/RemoveABTestScreen.tsx @@ -0,0 +1,26 @@ +import type { RemovableResource } from '../../../primitives/types'; +import type { SelectableItem } from '../../components'; +import { SelectScreen } from '../../components'; +import React, { useMemo } from 'react'; + +interface RemoveABTestScreenProps { + abTests: RemovableResource[]; + onSelect: (testName: string) => void; + onExit: () => void; +} + +export function RemoveABTestScreen({ abTests, onSelect, onExit }: RemoveABTestScreenProps) { + const items: SelectableItem[] = useMemo( + () => + abTests.map(t => ({ + id: t.name, + title: t.name, + description: 'AB Test', + })), + [abTests] + ); + + return ( + onSelect(item.id)} onExit={onExit} /> + ); +} diff --git a/src/cli/tui/screens/ab-test/TargetBasedABTestScreen.tsx b/src/cli/tui/screens/ab-test/TargetBasedABTestScreen.tsx new file mode 100644 index 000000000..60b92dd45 --- /dev/null +++ b/src/cli/tui/screens/ab-test/TargetBasedABTestScreen.tsx @@ -0,0 +1,712 @@ +import type { SelectableItem } from '../../components'; +import { + ConfirmReview, + Cursor, + Panel, + Screen, + StepIndicator, + TextInput, + TwoColumn, + WizardSelect, +} from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { usePanelNavigation } from '../../hooks/usePanelNavigation'; +import type { HttpGatewayInfo, OnlineEvalConfigInfo, RuntimeInfo } from './AddABTestScreen'; +import type { AddABTestConfig, TargetInfo } from './types'; +import { TARGET_BASED_STEP_LABELS, useTargetBasedWizard } from './useTargetBasedWizard'; +import { Box, Text, useInput } from 'ink'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +// ───────────────────────────────────────────────────────────────────────────── +// Props +// ───────────────────────────────────────────────────────────────────────────── + +interface TargetBasedABTestScreenProps { + onComplete: (config: AddABTestConfig) => void; + onExit: () => void; + existingTestNames: string[]; + runtimes: RuntimeInfo[]; + httpGatewayDetails: HttpGatewayInfo[]; + existingHttpGateways: string[]; + onlineEvalConfigDetails: OnlineEvalConfigInfo[]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Builder field indices +// ───────────────────────────────────────────────────────────────────────────── + +const FIELD_TARGET = 0; +const FIELD_WEIGHT = 1; +const FIELD_EVAL = 2; +const FIELD_COUNT = 3; + +// ───────────────────────────────────────────────────────────────────────────── +// VariantColumn sub-component +// ───────────────────────────────────────────────────────────────────────────── + +interface VariantColumnProps { + label: string; + color: string; + isActive: boolean; + focusedField: number | null; + activeField: number | null; + targetInfo: TargetInfo | null; + weight: number; + evalConfigName: string; + targetItems: SelectableItem[]; + targetNavIndex: number; + evalItems: SelectableItem[]; + evalNavIndex: number; + onWeightSubmit: (value: string) => void; + onWeightCancel: () => void; +} + +function VariantColumn({ + label, + color, + isActive, + focusedField, + activeField, + targetInfo, + weight, + evalConfigName, + targetItems, + targetNavIndex, + evalItems, + evalNavIndex, + onWeightSubmit, + onWeightCancel, +}: VariantColumnProps) { + const borderColor = isActive ? color : 'gray'; + + const fieldLabel = (idx: number, text: string, value: string) => { + const isFocused = focusedField === idx; + const isFieldActive = activeField === idx; + const prefix = isFocused || isFieldActive ? '>' : ' '; + const checkmark = value && value !== '(not set)' ? '\u2713 ' : ''; + + return ( + + + {prefix} {text}:{' '} + + + {checkmark} + {value} + + + ); + }; + + return ( + + + {label} + + + {/* Target field */} + {activeField === FIELD_TARGET ? ( + + ) : ( + fieldLabel( + FIELD_TARGET, + 'Target', + targetInfo ? `${targetInfo.name} (${targetInfo.runtimeRef}/${targetInfo.qualifier})` : '(not set)' + ) + )} + + {/* Weight field */} + {activeField === FIELD_WEIGHT ? ( + { + const w = parseInt(value, 10); + if (isNaN(w)) return 'Must be a number'; + if (w < 1 || w > 99) return 'Must be between 1 and 99'; + return true; + }} + /> + ) : ( + fieldLabel(FIELD_WEIGHT, 'Weight', `${weight}%`) + )} + + {/* Eval config field */} + {activeField === FIELD_EVAL ? ( + evalItems.length > 0 ? ( + + ) : ( + + No eval config found for this target. + Press Esc to go back. Create one with: agentcore add online-eval + + ) + ) : ( + fieldLabel(FIELD_EVAL, 'Eval', evalConfigName || '(optional)') + )} + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main Screen +// ───────────────────────────────────────────────────────────────────────────── + +export function TargetBasedABTestScreen({ + onComplete, + onExit, + existingTestNames, + runtimes, + httpGatewayDetails, + existingHttpGateways, + onlineEvalConfigDetails, +}: TargetBasedABTestScreenProps) { + const wizard = useTargetBasedWizard(); + + // ── Name/Description multi-field form ─────────────────────────────────── + type NameField = 'name' | 'description'; + const NAME_FIELDS: NameField[] = ['name', 'description']; + const [activeNameField, setActiveNameField] = useState('name'); + const [nameValue, setNameValue] = useState(''); + const [descriptionValue, setDescriptionValue] = useState(''); + const [nameError, setNameError] = useState(null); + const [gatewayCreateMode, setGatewayCreateMode] = useState(false); + + // Step flags + const isNameStep = wizard.step === 'nameDescription'; + const isGatewayStep = wizard.step === 'gateway'; + const isBuilderStep = wizard.step === 'builder'; + const isEnableStep = wizard.step === 'enableOnCreate'; + const isConfirmStep = wizard.step === 'confirm'; + + // ── Name/Description input handler ───────────────────────────────────── + useInput( + (input, key) => { + if (!isNameStep) return; + + if (key.escape) { + if (activeNameField === 'description') { + setActiveNameField('name'); + } else { + onExit(); + } + return; + } + + if (key.tab || key.upArrow || key.downArrow) { + const idx = NAME_FIELDS.indexOf(activeNameField); + if (key.shift || key.upArrow) { + setActiveNameField(NAME_FIELDS[(idx - 1 + NAME_FIELDS.length) % NAME_FIELDS.length]!); + } else { + setActiveNameField(NAME_FIELDS[(idx + 1) % NAME_FIELDS.length]!); + } + setNameError(null); + return; + } + + if (key.return) { + if (activeNameField === 'name') { + if (!nameValue.trim()) { + setNameError('Name is required'); + return; + } + if (!/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/.test(nameValue.trim())) { + setNameError('Must begin with a letter, alphanumeric + underscores only (max 48 chars)'); + return; + } + if (existingTestNames.includes(nameValue.trim())) { + setNameError(`AB test "${nameValue.trim()}" already exists`); + return; + } + setActiveNameField('description'); + setNameError(null); + return; + } + // On description, submit both + if (!nameValue.trim()) { + setNameError('Name is required'); + setActiveNameField('name'); + return; + } + wizard.setName(nameValue.trim()); + wizard.setDescription(descriptionValue.trim()); + wizard.advanceFromNameDescription(); + return; + } + + // Text input + if (key.backspace || key.delete) { + if (activeNameField === 'name') setNameValue(v => v.slice(0, -1)); + else setDescriptionValue(v => v.slice(0, -1)); + setNameError(null); + return; + } + if (input && !key.ctrl && !key.meta) { + if (activeNameField === 'name') setNameValue(v => v + input); + else setDescriptionValue(v => v + input); + setNameError(null); + } + }, + { isActive: isNameStep } + ); + + // ── Gateway items ─────────────────────────────────────────────────────── + const gatewayItems: SelectableItem[] = useMemo(() => { + const items: SelectableItem[] = []; + for (const gwName of existingHttpGateways) { + items.push({ id: gwName, title: gwName, description: 'Existing HTTP gateway' }); + } + items.push({ + id: '__create__', + title: 'Create new gateway', + description: 'Auto-create for this AB test', + }); + return items; + }, [existingHttpGateways]); + + // ── Target items builder ──────────────────────────────────────────────── + const buildTargetItems = useCallback( + (excludeTarget: TargetInfo | null): SelectableItem[] => { + const items: SelectableItem[] = []; + + // Section 1: Existing targets on the selected gateway + const selectedGw = httpGatewayDetails.find(g => g.name === wizard.config.gateway); + const existingTargets = selectedGw?.targets ?? []; + if (existingTargets.length > 0) { + items.push({ + id: '__section_existing__', + title: '── Gateway Targets ──', + description: '', + disabled: true, + }); + for (const t of existingTargets) { + if (t.name === excludeTarget?.name) continue; + items.push({ + id: `existing:${t.name}`, + title: t.name, + description: `${t.runtimeRef}/${t.qualifier}`, + }); + } + } + + // Section 2: Runtime endpoints (auto-create targets) + const endpointItems: SelectableItem[] = []; + for (const rt of runtimes) { + for (const ep of rt.endpoints) { + const targetName = `${rt.name}-${ep.name}`; + if (targetName === excludeTarget?.name) continue; + endpointItems.push({ + id: `endpoint:${rt.name}/${ep.name}`, + title: `${rt.name}/${ep.name}`, + description: `v${ep.version}`, + }); + } + } + if (endpointItems.length > 0) { + items.push({ + id: '__section_endpoints__', + title: '── Runtime Endpoints ──\n Select to auto-create target', + description: '', + disabled: true, + spaceBefore: items.length > 0, + }); + items.push(...endpointItems); + } + + return items; + }, + [httpGatewayDetails, runtimes, wizard.config.gateway] + ); + + const controlTargetItems = useMemo(() => buildTargetItems(null), [buildTargetItems]); + const treatmentTargetItems = useMemo( + () => buildTargetItems(wizard.config.controlTargetInfo), + [buildTargetItems, wizard.config.controlTargetInfo] + ); + + // ── Eval items (auto-matched by runtime + endpoint) ───────────────────── + const buildEvalItems = useCallback( + (targetInfo: TargetInfo | null): SelectableItem[] => { + if (!targetInfo) return []; + return onlineEvalConfigDetails + .filter(c => c.agent === targetInfo.runtimeRef && (c.endpoint ?? 'DEFAULT') === targetInfo.qualifier) + .map(c => ({ id: c.name, title: c.name, description: `${c.agent}/${c.endpoint ?? 'DEFAULT'}` })); + }, + [onlineEvalConfigDetails] + ); + + const controlEvalItems = useMemo( + () => buildEvalItems(wizard.config.controlTargetInfo), + [buildEvalItems, wizard.config.controlTargetInfo] + ); + const treatmentEvalItems = useMemo( + () => buildEvalItems(wizard.config.treatmentTargetInfo), + [buildEvalItems, wizard.config.treatmentTargetInfo] + ); + + // Auto-match eval when target is selected and exactly one match exists + useEffect(() => { + if (wizard.config.controlTargetInfo && controlEvalItems.length === 1 && !wizard.config.controlOnlineEval) { + wizard.setControlEval(controlEvalItems[0]!.id); + } + }, [wizard.config.controlTargetInfo, controlEvalItems, wizard.config.controlOnlineEval, wizard.setControlEval]); + + useEffect(() => { + if (wizard.config.treatmentTargetInfo && treatmentEvalItems.length === 1 && !wizard.config.treatmentOnlineEval) { + wizard.setTreatmentEval(treatmentEvalItems[0]!.id); + } + }, [ + wizard.config.treatmentTargetInfo, + treatmentEvalItems, + wizard.config.treatmentOnlineEval, + wizard.setTreatmentEval, + ]); + + // ── Enable items ──────────────────────────────────────────────────────── + const enableItems: SelectableItem[] = useMemo( + () => [ + { id: 'yes', title: 'Yes', description: 'Start the AB test immediately after deploy' }, + { id: 'no', title: 'No', description: 'Create paused — start manually later' }, + ], + [] + ); + + // ── Panel navigation for the builder step ─────────────────────────────── + const panel = usePanelNavigation({ + isActive: isBuilderStep, + fieldCount: FIELD_COUNT, + onExit: () => wizard.goBack(), + onComplete: () => wizard.advance(), + }); + + // ── Target selection handler ──────────────────────────────────────────── + const handleTargetSelect = useCallback( + (column: number, item: SelectableItem) => { + const setter = column === 0 ? wizard.setControlTarget : wizard.setTreatmentTarget; + + if (item.id.startsWith('existing:')) { + const targetName = item.id.replace('existing:', ''); + const selectedGw = httpGatewayDetails.find(g => g.name === wizard.config.gateway); + const target = selectedGw?.targets.find(t => t.name === targetName); + if (target) { + setter({ name: target.name, runtimeRef: target.runtimeRef, qualifier: target.qualifier }, false); + } + } else if (item.id.startsWith('endpoint:')) { + const path = item.id.replace('endpoint:', ''); + const [runtimeName, endpointName] = path.split('/'); + if (runtimeName && endpointName) { + const autoName = `${runtimeName}-${endpointName}`; + setter({ name: autoName, runtimeRef: runtimeName, qualifier: endpointName }, true); + } + } + panel.deactivate(); + }, + [httpGatewayDetails, wizard.config.gateway, wizard.setControlTarget, wizard.setTreatmentTarget, panel] + ); + + // ── List navigations for builder pickers ──────────────────────────────── + + // Control target picker + const controlTargetNav = useListNavigation({ + items: controlTargetItems, + onSelect: item => handleTargetSelect(0, item), + onExit: () => panel.deactivate(), + isActive: panel.isFieldActive(0, FIELD_TARGET), + isDisabled: item => item.disabled === true, + }); + + // Treatment target picker + const treatmentTargetNav = useListNavigation({ + items: treatmentTargetItems, + onSelect: item => handleTargetSelect(1, item), + onExit: () => panel.deactivate(), + isActive: panel.isFieldActive(1, FIELD_TARGET), + isDisabled: item => item.disabled === true, + }); + + // Control eval picker + const controlEvalNav = useListNavigation({ + items: controlEvalItems, + onSelect: item => { + wizard.setControlEval(item.id); + panel.deactivate(); + }, + onExit: () => panel.deactivate(), + isActive: panel.isFieldActive(0, FIELD_EVAL), + }); + + // Treatment eval picker + const treatmentEvalNav = useListNavigation({ + items: treatmentEvalItems, + onSelect: item => { + wizard.setTreatmentEval(item.id); + panel.deactivate(); + }, + onExit: () => panel.deactivate(), + isActive: panel.isFieldActive(1, FIELD_EVAL), + }); + + // ── Non-builder navigation hooks ──────────────────────────────────────── + + const gatewayNav = useListNavigation({ + items: gatewayItems, + onSelect: item => { + if (item.id === '__create__') { + setGatewayCreateMode(true); + return; + } + wizard.setGateway(item.id, false); + }, + onExit: () => wizard.goBack(), + isActive: isGatewayStep && !gatewayCreateMode, + isDisabled: item => item.disabled === true, + }); + + const enableNav = useListNavigation({ + items: enableItems, + onSelect: item => wizard.setEnableOnCreate(item.id === 'yes'), + onExit: () => wizard.goBack(), + isActive: isEnableStep, + }); + + // Builder "Continue" navigation — when all fields filled, Enter on confirm row advances + const builderContinueItems: SelectableItem[] = useMemo( + () => (wizard.isBuilderComplete ? [{ id: 'continue', title: 'Continue' }] : []), + [wizard.isBuilderComplete] + ); + + const _builderContinueNav = useListNavigation({ + items: builderContinueItems, + onSelect: () => wizard.advance(), + onExit: () => wizard.goBack(), + isActive: false, // Controlled programmatically below + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(wizard.toAddABTestConfig()), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + // ── Help text ─────────────────────────────────────────────────────────── + const isSelectStep = (isGatewayStep && !gatewayCreateMode) || isEnableStep; + const helpText = isSelectStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : isBuilderStep + ? 'Tab switch column \u00B7 \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc back' + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ( + + ); + + // ── Format display helpers ────────────────────────────────────────────── + const formatTargetDisplay = (info: TargetInfo | null, isNew: boolean): string => { + if (!info) return '(not set)'; + const newLabel = isNew ? ' (new)' : ''; + return `${info.name} endpoint=${info.qualifier} runtime=${info.runtimeRef}${newLabel}`; + }; + + // ── Weight submit handlers ────────────────────────────────────────────── + const handleControlWeightSubmit = useCallback( + (value: string) => { + const w = parseInt(value, 10); + if (!isNaN(w) && w >= 1 && w <= 99) { + wizard.setControlWeight(w); + } + panel.deactivate(); + }, + [wizard, panel] + ); + + const handleTreatmentWeightSubmit = useCallback( + (value: string) => { + const w = parseInt(value, 10); + if (!isNaN(w) && w >= 1 && w <= 99) { + // Treatment weight setter: set control to 100 - treatment + wizard.setControlWeight(100 - w); + } + panel.deactivate(); + }, + [wizard, panel] + ); + + const handleWeightCancel = useCallback(() => { + panel.deactivate(); + }, [panel]); + + return ( + + + {/* ── Step 1: Name + Description ─────────────────────── */} + {isNameStep && ( + + + {'Name: '} + {activeNameField === 'name' && !nameValue && } + + {nameValue || {'e.g., my-ab-test'}} + + {activeNameField === 'name' && nameValue ? : null} + + + {'Description: '} + {activeNameField === 'description' && !descriptionValue && } + + {descriptionValue || {'(optional)'}} + + {activeNameField === 'description' && descriptionValue ? : null} + + {nameError && ( + + {nameError} + + )} + + )} + + {/* ── Step 2: Gateway ────────────────────────────────── */} + {isGatewayStep && !gatewayCreateMode && ( + + )} + {isGatewayStep && gatewayCreateMode && ( + { + wizard.setGateway(name, true); + setGatewayCreateMode(false); + }} + onCancel={() => setGatewayCreateMode(false)} + /> + )} + + {/* ── Step 3: Side-by-Side Builder ───────────────────── */} + {isBuilderStep && ( + + + } + right={ + + } + /> + {wizard.isBuilderComplete && ( + + + {'\u2713'} All fields configured. Press Enter to continue, or adjust values above. + + + )} + {!wizard.isBuilderComplete && ( + + Configure both columns, then press Enter to continue. + + )} + + )} + + {/* ── Step 4: Enable on Create ───────────────────────── */} + {isEnableStep && ( + + )} + + {/* ── Step 5: Confirm ────────────────────────────────── */} + {isConfirmStep && ( + + )} + + + ); +} diff --git a/src/cli/tui/screens/ab-test/VariantConfigForm.tsx b/src/cli/tui/screens/ab-test/VariantConfigForm.tsx new file mode 100644 index 000000000..61f465323 --- /dev/null +++ b/src/cli/tui/screens/ab-test/VariantConfigForm.tsx @@ -0,0 +1,268 @@ +import type { SelectableItem } from '../../components'; +import { TextInput, WizardSelect } from '../../components'; +import { useListNavigation } from '../../hooks'; +import { Box, Text } from 'ink'; +import React, { useCallback, useMemo, useState } from 'react'; + +type VariantSubField = 'controlBundle' | 'controlVersion' | 'treatmentBundle' | 'treatmentVersion' | 'treatmentWeight'; + +const SUB_FIELDS: VariantSubField[] = [ + 'controlBundle', + 'controlVersion', + 'treatmentBundle', + 'treatmentVersion', + 'treatmentWeight', +]; + +export interface VariantConfig { + controlBundle: string; + controlVersion: string; + treatmentBundle: string; + treatmentVersion: string; + treatmentWeight: number; +} + +export type VersionLoadState = 'idle' | 'loading' | 'loaded' | 'error'; + +interface VariantConfigFormProps { + bundleItems: SelectableItem[]; + fetchVersionItems: (bundleName: string) => void; + controlVersionItems: SelectableItem[]; + treatmentVersionItems: SelectableItem[]; + controlVersionLoadState: VersionLoadState; + treatmentVersionLoadState: VersionLoadState; + onComplete: (config: VariantConfig) => void; + onCancel: () => void; + onCreateBundle?: () => void; +} + +export function VariantConfigForm({ + bundleItems, + fetchVersionItems, + controlVersionItems, + treatmentVersionItems, + controlVersionLoadState, + treatmentVersionLoadState, + onComplete, + onCancel, + onCreateBundle, +}: VariantConfigFormProps) { + const [activeField, setActiveField] = useState('controlBundle'); + const [controlBundle, setControlBundle] = useState(''); + const [controlVersion, setControlVersion] = useState(''); + const [treatmentBundle, setTreatmentBundle] = useState(''); + const [treatmentVersion, setTreatmentVersion] = useState(''); + const [treatmentWeight, setTreatmentWeight] = useState('20'); + + const augmentedBundleItems: SelectableItem[] = useMemo(() => { + const items: SelectableItem[] = []; + if (onCreateBundle) { + items.push({ id: '__create_bundle__', title: 'Create new config bundle', description: 'Add a new bundle first' }); + } + items.push(...bundleItems); + return items; + }, [bundleItems, onCreateBundle]); + + const advanceField = useCallback(() => { + const idx = SUB_FIELDS.indexOf(activeField); + const next = SUB_FIELDS[idx + 1]; + if (next) setActiveField(next); + }, [activeField]); + + // Navigation for each select sub-field + const controlBundleNav = useListNavigation({ + items: augmentedBundleItems, + onSelect: item => { + if (item.id === '__create_bundle__') { + onCreateBundle?.(); + return; + } + setControlBundle(item.id); + fetchVersionItems(item.id); + advanceField(); + }, + onExit: onCancel, + isActive: activeField === 'controlBundle', + }); + + const controlVersionNav = useListNavigation({ + items: controlVersionItems, + onSelect: item => { + setControlVersion(item.id); + advanceField(); + }, + onExit: () => setActiveField('controlBundle'), + isActive: activeField === 'controlVersion' && controlVersionLoadState === 'loaded', + }); + + const treatmentBundleNav = useListNavigation({ + items: augmentedBundleItems, + onSelect: item => { + if (item.id === '__create_bundle__') { + onCreateBundle?.(); + return; + } + setTreatmentBundle(item.id); + fetchVersionItems(item.id); + advanceField(); + }, + onExit: () => setActiveField('controlVersion'), + isActive: activeField === 'treatmentBundle', + }); + + const treatmentVersionNav = useListNavigation({ + items: treatmentVersionItems, + onSelect: item => { + setTreatmentVersion(item.id); + advanceField(); + }, + onExit: () => setActiveField('treatmentBundle'), + isActive: activeField === 'treatmentVersion' && treatmentVersionLoadState === 'loaded', + }); + + const controlWeight = 100 - parseInt(treatmentWeight || '0', 10); + + const completedValue = (value: string, label: string) => ( + + {label}: + {value || '(pending)'} + {value && } + + ); + + const pendingValue = (label: string) => ( + + {label}: + (pending) + + ); + + const renderVersionField = ( + isActive: boolean, + loadState: VersionLoadState, + items: SelectableItem[], + nav: { selectedIndex: number }, + title: string, + completedVersion: string, + label: string + ) => { + if (!isActive) { + return completedVersion ? completedValue(completedVersion.slice(0, 8), label) : pendingValue(label); + } + + switch (loadState) { + case 'loading': + return {label}: Loading versions...; + case 'error': + return {label}: Failed to load versions. Press Esc to go back and retry.; + case 'loaded': + if (items.length === 0) { + return {label}: No versions found. Deploy the config bundle first.; + } + return ; + default: + return {label}: Waiting...; + } + }; + + return ( + + Configure Variants + + {/* Control section */} + + + Control (C): + + + {activeField === 'controlBundle' ? ( + augmentedBundleItems.length > 0 ? ( + + ) : ( + No deployed config bundles found. + ) + ) : ( + completedValue(controlBundle, ' Bundle') + )} + + {renderVersionField( + activeField === 'controlVersion', + controlVersionLoadState, + controlVersionItems, + controlVersionNav, + ' Select control version', + controlVersion, + ' Version' + )} + + + {/* Treatment section */} + + + Treatment (T1): + + + {activeField === 'treatmentBundle' ? ( + + ) : treatmentBundle ? ( + completedValue(treatmentBundle, ' Bundle') + ) : ( + pendingValue(' Bundle') + )} + + {renderVersionField( + activeField === 'treatmentVersion', + treatmentVersionLoadState, + treatmentVersionItems, + treatmentVersionNav, + ' Select treatment version', + treatmentVersion, + ' Version' + )} + + {activeField === 'treatmentWeight' ? ( + + setTreatmentWeight(value)} + onSubmit={value => { + const n = parseInt(value, 10); + if (!isNaN(n) && n >= 1 && n <= 99) { + setTreatmentWeight(value); + onComplete({ + controlBundle, + controlVersion, + treatmentBundle, + treatmentVersion, + treatmentWeight: n, + }); + } + }} + onCancel={() => setActiveField('treatmentVersion')} + customValidation={(value: string) => { + const n = parseInt(value, 10); + if (isNaN(n)) return 'Must be a number'; + if (n < 1 || n > 99) return 'Must be between 1 and 99'; + return true; + }} + /> + + ) : treatmentWeight && treatmentVersion ? ( + completedValue(`${treatmentWeight}% (control: ${controlWeight}%)`, ' Weight') + ) : ( + pendingValue(' Weight') + )} + + + ); +} diff --git a/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx b/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx new file mode 100644 index 000000000..082d7662b --- /dev/null +++ b/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx @@ -0,0 +1,286 @@ +import type { VariantConfig } from '../VariantConfigForm'; +import type { GatewayChoice } from '../types'; +import type { StepSkipCheck } from '../useAddABTestWizard'; +import { useAddABTestWizard } from '../useAddABTestWizard'; +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; +import React, { act, useImperativeHandle } from 'react'; +import { describe, expect, it } from 'vitest'; + +// ── Simple harness ───────────────────────────────────────────────────────── + +function Harness() { + const wizard = useAddABTestWizard(); + return ( + + step:{wizard.step} + name:{wizard.config.name} + treatmentWeight:{wizard.config.treatmentWeight} + enableOnCreate:{String(wizard.config.enableOnCreate)} + steps:{wizard.steps.join(',')} + + ); +} + +// ── Imperative harness ───────────────────────────────────────────────────── + +interface HarnessHandle { + setName: (name: string) => void; + setDescription: (desc: string) => void; + setAgent: (agent: string) => void; + setGateway: (choice: GatewayChoice) => void; + setVariants: (vc: VariantConfig) => void; + setOnlineEval: (eval_: string) => void; + setMaxDuration: (days: number | undefined) => void; + setEnableOnCreate: (enable: boolean) => void; + setSkipCheck: (check: StepSkipCheck) => void; + goBack: () => void; + reset: () => void; +} + +const ImperativeHarness = React.forwardRef((_, ref) => { + const wizard = useAddABTestWizard(); + useImperativeHandle(ref, () => ({ + setName: wizard.setName, + setDescription: wizard.setDescription, + setAgent: wizard.setAgent, + setGateway: wizard.setGateway, + setVariants: wizard.setVariants, + setOnlineEval: wizard.setOnlineEval, + setMaxDuration: wizard.setMaxDuration, + setEnableOnCreate: wizard.setEnableOnCreate, + setSkipCheck: wizard.setSkipCheck, + goBack: wizard.goBack, + reset: wizard.reset, + })); + return ( + + step:{wizard.step} + name:{wizard.config.name} + description:{wizard.config.description} + agent:{wizard.config.agent} + controlBundle:{wizard.config.controlBundle} + treatmentWeight:{wizard.config.treatmentWeight} + onlineEval:{wizard.config.onlineEval} + maxDuration:{String(wizard.config.maxDuration ?? 'undefined')} + enableOnCreate:{String(wizard.config.enableOnCreate)} + + ); +}); +ImperativeHarness.displayName = 'ImperativeHarness'; + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('useAddABTestWizard', () => { + describe('defaults', () => { + it('default step is mode', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('step:mode'); + }); + + it('default treatment weight is 20', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('treatmentWeight:20'); + }); + + it('default enableOnCreate is true', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('enableOnCreate:true'); + }); + + it('has all 10 steps', () => { + const { lastFrame } = render(); + const frame = lastFrame()!.replace(/\n/g, ''); + expect(frame).toContain( + 'steps:mode,name,description,gateway,agent,variants,onlineEval,maxDuration,enableOnCreate,confirm' + ); + }); + }); + + describe('step navigation', () => { + it('setName advances to description', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setName('Test1')); + + expect(lastFrame()).toContain('step:description'); + expect(lastFrame()).toContain('name:Test1'); + }); + + it('setDescription advances to gateway', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setName('Test1')); + act(() => ref.current!.setDescription('desc')); + + expect(lastFrame()).toContain('step:gateway'); + expect(lastFrame()).toContain('description:desc'); + }); + + it('setGateway advances to agent', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setName('T')); + act(() => ref.current!.setDescription('')); + act(() => ref.current!.setGateway({ type: 'create-new' })); + + expect(lastFrame()).toContain('step:agent'); + }); + + it('setAgent advances to variants', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setName('T')); + act(() => ref.current!.setDescription('')); + act(() => ref.current!.setGateway({ type: 'create-new' })); + act(() => ref.current!.setAgent('my-agent')); + + expect(lastFrame()).toContain('step:variants'); + expect(lastFrame()).toContain('agent:my-agent'); + }); + + it('setVariants advances to onlineEval', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setName('T')); + act(() => ref.current!.setDescription('')); + act(() => ref.current!.setGateway({ type: 'create-new' })); + act(() => ref.current!.setAgent('my-agent')); + act(() => + ref.current!.setVariants({ + controlBundle: 'cb', + controlVersion: 'v1', + treatmentBundle: 'tb', + treatmentVersion: 'v2', + treatmentWeight: 30, + }) + ); + + expect(lastFrame()).toContain('step:onlineEval'); + expect(lastFrame()).toContain('controlBundle:cb'); + expect(lastFrame()).toContain('treatmentWeight:30'); + }); + + it('full wizard reaches confirm step', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setName('T')); + act(() => ref.current!.setDescription('')); + act(() => ref.current!.setGateway({ type: 'create-new' })); + act(() => ref.current!.setAgent('my-agent')); + act(() => + ref.current!.setVariants({ + controlBundle: 'cb', + controlVersion: 'v1', + treatmentBundle: 'tb', + treatmentVersion: 'v2', + treatmentWeight: 25, + }) + ); + act(() => ref.current!.setOnlineEval('eval-arn')); + act(() => ref.current!.setMaxDuration(30)); + act(() => ref.current!.setEnableOnCreate(false)); + + const frame = lastFrame()!.replace(/\n/g, ''); + expect(frame).toContain('step:confirm'); + expect(frame).toContain('enableOnCreate:false'); + expect(frame).toContain('maxDuration:30'); + }); + }); + + describe('goBack', () => { + it('goes back from description to name', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setName('T')); + expect(lastFrame()).toContain('step:description'); + + act(() => ref.current!.goBack()); + expect(lastFrame()).toContain('step:name'); + }); + + it('does not go back from first step', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.goBack()); + expect(lastFrame()).toContain('step:mode'); + }); + }); + + describe('reset', () => { + it('resets to initial state', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setName('Test1')); + act(() => ref.current!.setDescription('desc')); + expect(lastFrame()).toContain('step:gateway'); + + act(() => ref.current!.reset()); + + expect(lastFrame()).toContain('step:mode'); + expect(lastFrame()).toContain('name:'); + expect(lastFrame()).toContain('treatmentWeight:20'); + }); + }); + + describe('skip check', () => { + it('advance skips over steps marked as skippable', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setSkipCheck(s => s === 'gateway')); + act(() => ref.current!.setName('T')); + act(() => ref.current!.setDescription('')); + act(() => ref.current!.setAgent('my-agent')); + + expect(lastFrame()).toContain('step:variants'); + }); + + it('goBack skips over steps marked as skippable', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setName('T')); + act(() => ref.current!.setDescription('')); + act(() => ref.current!.setGateway({ type: 'create-new' })); + act(() => ref.current!.setAgent('my-agent')); + expect(lastFrame()).toContain('step:variants'); + + act(() => ref.current!.setSkipCheck(s => s === 'agent')); + act(() => ref.current!.goBack()); + + expect(lastFrame()).toContain('step:gateway'); + }); + + it('advance skips multiple consecutive skippable steps', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setSkipCheck(s => s === 'agent' || s === 'variants')); + act(() => ref.current!.setName('T')); + act(() => ref.current!.setDescription('')); + act(() => ref.current!.setGateway({ type: 'create-new' })); + + expect(lastFrame()).toContain('step:onlineEval'); + }); + + it('skip check does not affect non-skippable steps', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setSkipCheck(() => false)); + act(() => ref.current!.setName('T')); + + expect(lastFrame()).toContain('step:description'); + }); + }); +}); diff --git a/src/cli/tui/screens/ab-test/__tests__/useTargetBasedWizard.test.tsx b/src/cli/tui/screens/ab-test/__tests__/useTargetBasedWizard.test.tsx new file mode 100644 index 000000000..4ea0a40d5 --- /dev/null +++ b/src/cli/tui/screens/ab-test/__tests__/useTargetBasedWizard.test.tsx @@ -0,0 +1,319 @@ +import type { TargetInfo } from '../types'; +import { useTargetBasedWizard } from '../useTargetBasedWizard'; +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; +import React, { act, useImperativeHandle } from 'react'; +import { describe, expect, it } from 'vitest'; + +// ── Simple harness ───────────────────────────────────────────────────────── + +function Harness() { + const wizard = useTargetBasedWizard(); + return ( + + step:{wizard.step} + name:{wizard.config.name} + description:{wizard.config.description} + gateway:{wizard.config.gateway} + controlWeight:{wizard.config.controlWeight} + treatmentWeight:{wizard.config.treatmentWeight} + enableOnCreate:{String(wizard.config.enableOnCreate)} + + ); +} + +// ── Imperative harness ───────────────────────────────────────────────────── + +interface HarnessHandle { + setName: (name: string) => void; + setDescription: (desc: string) => void; + advanceFromNameDescription: () => void; + setGateway: (name: string, isNew: boolean) => void; + advance: () => void; + goBack: () => void; + setControlTarget: (target: TargetInfo, isNew: boolean) => void; + setTreatmentTarget: (target: TargetInfo, isNew: boolean) => void; + setControlWeight: (w: number) => void; + setControlEval: (name: string) => void; + setTreatmentEval: (name: string) => void; + setEnableOnCreate: (enable: boolean) => void; + toAddABTestConfig: ReturnType['toAddABTestConfig']; +} + +const ImperativeHarness = React.forwardRef((_, ref) => { + const wizard = useTargetBasedWizard(); + useImperativeHandle(ref, () => ({ + setName: wizard.setName, + setDescription: wizard.setDescription, + advanceFromNameDescription: wizard.advanceFromNameDescription, + setGateway: wizard.setGateway, + advance: wizard.advance, + goBack: wizard.goBack, + setControlTarget: wizard.setControlTarget, + setTreatmentTarget: wizard.setTreatmentTarget, + setControlWeight: wizard.setControlWeight, + setControlEval: wizard.setControlEval, + setTreatmentEval: wizard.setTreatmentEval, + setEnableOnCreate: wizard.setEnableOnCreate, + toAddABTestConfig: wizard.toAddABTestConfig, + })); + const ctrlName = wizard.config.controlTargetInfo ? wizard.config.controlTargetInfo.name : 'null'; + const treatName = wizard.config.treatmentTargetInfo ? wizard.config.treatmentTargetInfo.name : 'null'; + return ( + + {[ + `step:${wizard.step}`, + `name:${wizard.config.name}`, + `description:${wizard.config.description}`, + `gateway:${wizard.config.gateway}`, + `gatewayIsNew:${String(wizard.config.gatewayIsNew)}`, + `controlWeight:${wizard.config.controlWeight}`, + `treatmentWeight:${wizard.config.treatmentWeight}`, + `controlOnlineEval:${wizard.config.controlOnlineEval}`, + `treatmentOnlineEval:${wizard.config.treatmentOnlineEval}`, + `enableOnCreate:${String(wizard.config.enableOnCreate)}`, + `controlTargetInfo:${ctrlName}`, + `treatmentTargetInfo:${treatName}`, + ].join('|')} + + ); +}); +ImperativeHarness.displayName = 'ImperativeHarness'; + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('useTargetBasedWizard', () => { + describe('defaults', () => { + it('initial step is nameDescription', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('step:nameDescription'); + }); + + it('default weights are 90/10', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('controlWeight:90'); + expect(lastFrame()).toContain('treatmentWeight:10'); + }); + + it('default enableOnCreate is true', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('enableOnCreate:true'); + }); + }); + + describe('step navigation', () => { + it('advanceFromNameDescription moves to gateway step', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.advanceFromNameDescription()); + + expect(lastFrame()).toContain('step:gateway'); + }); + + it('advance from gateway moves to builder', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.advanceFromNameDescription()); + // setGateway auto-advances to builder + act(() => ref.current!.setGateway('my-gw', false)); + + expect(lastFrame()).toContain('step:builder'); + }); + + it('advance from builder moves to enableOnCreate', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.advanceFromNameDescription()); + act(() => ref.current!.setGateway('my-gw', false)); + // Now at builder, advance to enableOnCreate + act(() => ref.current!.advance()); + + expect(lastFrame()).toContain('step:enableOnCreate'); + }); + + it('advance from enableOnCreate moves to confirm', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.advanceFromNameDescription()); + act(() => ref.current!.setGateway('my-gw', false)); + act(() => ref.current!.advance()); // builder → enableOnCreate + act(() => ref.current!.setEnableOnCreate(true)); // enableOnCreate → confirm + + expect(lastFrame()).toContain('step:confirm'); + }); + }); + + describe('goBack', () => { + it('goBack from gateway goes to nameDescription', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.advanceFromNameDescription()); + expect(lastFrame()).toContain('step:gateway'); + + act(() => ref.current!.goBack()); + expect(lastFrame()).toContain('step:nameDescription'); + }); + + it('goBack from builder goes to gateway', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.advanceFromNameDescription()); + act(() => ref.current!.setGateway('my-gw', false)); + expect(lastFrame()).toContain('step:builder'); + + act(() => ref.current!.goBack()); + expect(lastFrame()).toContain('step:gateway'); + }); + }); + + describe('config updates', () => { + it('setName updates config', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setName('MyTest')); + + expect(lastFrame()).toContain('name:MyTest'); + }); + + it('setDescription updates config', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setDescription('desc1')); + + expect(lastFrame()).toContain('description:desc1'); + }); + + it('setGateway updates config', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.advanceFromNameDescription()); + act(() => ref.current!.setGateway('gw-123', true)); + + expect(lastFrame()).toContain('gateway:gw-123'); + expect(lastFrame()).toContain('gatewayIsNew:true'); + }); + + it('setControlTarget updates config with targetInfo', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + const target: TargetInfo = { name: 'ctrl-target', runtimeRef: 'arn:runtime:1', qualifier: 'DEFAULT' }; + act(() => ref.current!.setControlTarget(target, false)); + + expect(lastFrame()).toContain('controlTargetInfo:ctrl-target'); + }); + + it('setTreatmentTarget updates config with targetInfo', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + const target: TargetInfo = { name: 'tt1', runtimeRef: 'arn:runtime:2', qualifier: 'v2' }; + act(() => ref.current!.setTreatmentTarget(target, true)); + + const frame = lastFrame()!.replace(/\n/g, ''); + expect(frame).toContain('treatmentTargetInfo:tt1'); + }); + + it('setControlWeight updates config (sum to 100)', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setControlWeight(70)); + + const frame = lastFrame()!.replace(/\n/g, ''); + expect(frame).toContain('controlWeight:70'); + expect(frame).toContain('treatmentWeight:30'); + }); + + it('setControlEval updates config', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setControlEval('eval-arn-1')); + + expect(lastFrame()).toContain('controlOnlineEval:eval-arn-1'); + }); + + it('setTreatmentEval updates config', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => ref.current!.setTreatmentEval('eval-arn-2')); + + expect(lastFrame()).toContain('treatmentOnlineEval:eval-arn-2'); + }); + }); + + describe('toAddABTestConfig', () => { + it('returns correct AddABTestConfig shape', () => { + const ref = React.createRef(); + render(); + + const controlTarget: TargetInfo = { name: 'ctrl', runtimeRef: 'arn:runtime:1', qualifier: 'DEFAULT' }; + const treatmentTarget: TargetInfo = { name: 'treat', runtimeRef: 'arn:runtime:2', qualifier: 'v2' }; + + act(() => ref.current!.setName('TestAB')); + act(() => ref.current!.setDescription('A/B test')); + act(() => ref.current!.advanceFromNameDescription()); + act(() => ref.current!.setGateway('my-gateway', false)); + act(() => ref.current!.setControlTarget(controlTarget, false)); + act(() => ref.current!.setTreatmentTarget(treatmentTarget, true)); + act(() => ref.current!.setControlWeight(80)); + act(() => ref.current!.setControlEval('eval-1')); + act(() => ref.current!.setTreatmentEval('eval-2')); + + let config: ReturnType | undefined; + act(() => { + config = ref.current!.toAddABTestConfig(); + }); + + expect(config).toBeDefined(); + expect(config!.mode).toBe('target-based'); + expect(config!.name).toBe('TestAB'); + expect(config!.description).toBe('A/B test'); + expect(config!.gateway).toBe('my-gateway'); + expect(config!.gatewayIsNew).toBe(false); + expect(config!.gatewayChoice).toEqual({ type: 'existing-http', name: 'my-gateway' }); + expect(config!.controlTargetInfo).toEqual(controlTarget); + expect(config!.controlTargetIsNew).toBe(false); + expect(config!.treatmentTargetInfo).toEqual(treatmentTarget); + expect(config!.treatmentTargetIsNew).toBe(true); + expect(config!.controlWeight).toBe(80); + expect(config!.treatmentWeight).toBe(20); + expect(config!.controlOnlineEval).toBe('eval-1'); + expect(config!.treatmentOnlineEval).toBe('eval-2'); + expect(config!.runtime).toBe('arn:runtime:1'); + expect(config!.controlTarget).toBe('ctrl'); + expect(config!.controlEndpoint).toBe('DEFAULT'); + expect(config!.treatmentTarget).toBe('treat'); + expect(config!.treatmentEndpoint).toBe('v2'); + expect(config!.enableOnCreate).toBe(true); + expect(config!.evaluators).toEqual([]); + expect(config!.samplingRate).toBe(10); + }); + + it('returns create-new gatewayChoice when gatewayIsNew is true', () => { + const ref = React.createRef(); + render(); + + act(() => ref.current!.advanceFromNameDescription()); + act(() => ref.current!.setGateway('new-gw', true)); + + let config: ReturnType | undefined; + act(() => { + config = ref.current!.toAddABTestConfig(); + }); + + expect(config!.gatewayChoice).toEqual({ type: 'create-new' }); + }); + }); +}); diff --git a/src/cli/tui/screens/ab-test/index.ts b/src/cli/tui/screens/ab-test/index.ts new file mode 100644 index 000000000..162b24eb9 --- /dev/null +++ b/src/cli/tui/screens/ab-test/index.ts @@ -0,0 +1,4 @@ +export { AddABTestFlow } from './AddABTestFlow'; +export { ABTestDetailScreen } from './ABTestDetailScreen'; +export { ABTestPickerScreen } from './ABTestPickerScreen'; +export { RemoveABTestScreen } from './RemoveABTestScreen'; diff --git a/src/cli/tui/screens/ab-test/types.ts b/src/cli/tui/screens/ab-test/types.ts new file mode 100644 index 000000000..977a2ca07 --- /dev/null +++ b/src/cli/tui/screens/ab-test/types.ts @@ -0,0 +1,89 @@ +// ───────────────────────────────────────────────────────────────────────────── +// AB Test Wizard Types +// ───────────────────────────────────────────────────────────────────────────── + +export type ABTestMode = 'config-bundle' | 'target-based'; + +export type AddABTestStep = + | 'mode' + | 'name' + | 'description' + | 'agent' + | 'gateway' + | 'variants' + | 'controlTarget' + | 'treatmentTarget' + | 'weights' + | 'evalPath' + | 'evalSelect' + | 'evalCreate' + | 'evalSamplingRate' + | 'onlineEval' + | 'maxDuration' + | 'enableOnCreate' + | 'confirm'; + +export type GatewayChoice = { type: 'create-new' } | { type: 'existing-http'; name: string }; + +/** Rich target info for target-based AB testing. */ +export interface TargetInfo { + name: string; + runtimeRef: string; + qualifier: string; +} + +export interface AddABTestConfig { + mode: ABTestMode; + name: string; + description: string; + agent: string; + gatewayChoice: GatewayChoice; + // Config-bundle mode + controlBundle: string; + controlVersion: string; + treatmentBundle: string; + treatmentVersion: string; + treatmentWeight: number; + onlineEval: string; + // Target-based mode fields + gateway: string; + gatewayIsNew: boolean; + controlTargetInfo: TargetInfo | null; + controlTargetIsNew: boolean; + treatmentTargetInfo: TargetInfo | null; + treatmentTargetIsNew: boolean; + // Legacy target-based fields (populated from TargetInfo for downstream compatibility) + runtime: string; + controlTarget: string; + controlEndpoint: string; + treatmentTarget: string; + treatmentEndpoint: string; + controlWeight: number; + controlOnlineEval: string; + treatmentOnlineEval: string; + evaluators: string[]; + samplingRate: number; + // Shared + maxDuration: number | undefined; + enableOnCreate: boolean; +} + +export const AB_TEST_STEP_LABELS: Record = { + mode: 'Mode', + name: 'Name', + description: 'Description', + agent: 'Agent', + gateway: 'Gateway', + variants: 'Variants', + controlTarget: 'Control', + treatmentTarget: 'Treatment', + weights: 'Weights', + evalPath: 'Eval', + evalSelect: 'Eval', + evalCreate: 'Eval', + evalSamplingRate: 'Eval', + onlineEval: 'Eval', + maxDuration: 'Duration', + enableOnCreate: 'Enable', + confirm: 'Confirm', +}; diff --git a/src/cli/tui/screens/ab-test/useAddABTestWizard.ts b/src/cli/tui/screens/ab-test/useAddABTestWizard.ts new file mode 100644 index 000000000..bb4fef0ad --- /dev/null +++ b/src/cli/tui/screens/ab-test/useAddABTestWizard.ts @@ -0,0 +1,324 @@ +import type { VariantConfig } from './VariantConfigForm'; +import type { ABTestMode, AddABTestConfig, AddABTestStep, GatewayChoice, TargetInfo } from './types'; +import { useCallback, useRef, useState } from 'react'; + +const CONFIG_BUNDLE_STEPS: AddABTestStep[] = [ + 'mode', + 'name', + 'description', + 'gateway', + 'agent', + 'variants', + 'onlineEval', + 'maxDuration', + 'enableOnCreate', + 'confirm', +]; + +const TARGET_BASED_STEPS: AddABTestStep[] = [ + 'mode', + 'name', + 'description', + 'gateway', + 'controlTarget', + 'treatmentTarget', + 'weights', + 'evalSelect', + 'enableOnCreate', + 'confirm', +]; + +function getDefaultConfig(): AddABTestConfig { + return { + mode: 'config-bundle', + name: '', + description: '', + agent: '', + gatewayChoice: { type: 'create-new' }, + controlBundle: '', + controlVersion: '', + treatmentBundle: '', + treatmentVersion: '', + treatmentWeight: 20, + onlineEval: '', + // Target-based mode fields + gateway: '', + gatewayIsNew: false, + controlTargetInfo: null, + controlTargetIsNew: false, + treatmentTargetInfo: null, + treatmentTargetIsNew: false, + // Legacy target-based fields + runtime: '', + controlTarget: '', + controlEndpoint: '', + treatmentTarget: '', + treatmentEndpoint: '', + controlWeight: 90, + controlOnlineEval: '', + treatmentOnlineEval: '', + evaluators: [], + samplingRate: 10, + maxDuration: undefined, + enableOnCreate: true, + }; +} + +export type StepSkipCheck = (step: AddABTestStep) => boolean; + +export function useAddABTestWizard() { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState('mode'); + const skipCheckRef = useRef(() => false); + + const getSteps = useCallback((): AddABTestStep[] => { + return config.mode === 'target-based' ? TARGET_BASED_STEPS : CONFIG_BUNDLE_STEPS; + }, [config.mode]); + + const currentIndex = getSteps().indexOf(step); + + const setSkipCheck = useCallback((check: StepSkipCheck) => { + skipCheckRef.current = check; + }, []); + + const goBack = useCallback(() => { + const steps = getSteps(); + for (let i = currentIndex - 1; i >= 0; i--) { + if (!skipCheckRef.current(steps[i]!)) { + setStep(steps[i]!); + return; + } + } + }, [currentIndex, getSteps]); + + const nextStep = useCallback( + (currentStepName: AddABTestStep): AddABTestStep | undefined => { + const steps = getSteps(); + const idx = steps.indexOf(currentStepName); + for (let i = idx + 1; i < steps.length; i++) { + if (!skipCheckRef.current(steps[i]!)) { + return steps[i]!; + } + } + return undefined; + }, + [getSteps] + ); + + const advance = useCallback( + (from: AddABTestStep) => { + const next = nextStep(from); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMode = useCallback( + (mode: ABTestMode) => { + setConfig(c => ({ ...c, mode })); + advance('mode'); + }, + [advance] + ); + + const setName = useCallback( + (name: string) => { + setConfig(c => ({ ...c, name })); + advance('name'); + }, + [advance] + ); + + const setDescription = useCallback( + (description: string) => { + setConfig(c => ({ ...c, description })); + advance('description'); + }, + [advance] + ); + + const setAgent = useCallback( + (agent: string) => { + setConfig(c => ({ ...c, agent })); + advance('agent'); + }, + [advance] + ); + + const setGateway = useCallback( + (gatewayChoice: GatewayChoice) => { + setConfig(c => ({ + ...c, + gatewayChoice, + gateway: gatewayChoice.type === 'existing-http' ? gatewayChoice.name : '', + gatewayIsNew: gatewayChoice.type === 'create-new', + })); + advance('gateway'); + }, + [advance] + ); + + const setGatewayWithName = useCallback( + (gatewayName: string, isNew: boolean) => { + const gatewayChoice: GatewayChoice = isNew + ? { type: 'create-new' } + : { type: 'existing-http', name: gatewayName }; + setConfig(c => ({ + ...c, + gatewayChoice, + gateway: gatewayName, + gatewayIsNew: isNew, + })); + advance('gateway'); + }, + [advance] + ); + + const setVariants = useCallback( + (variantConfig: VariantConfig) => { + setConfig(c => ({ + ...c, + controlBundle: variantConfig.controlBundle, + controlVersion: variantConfig.controlVersion, + treatmentBundle: variantConfig.treatmentBundle, + treatmentVersion: variantConfig.treatmentVersion, + treatmentWeight: variantConfig.treatmentWeight, + })); + advance('variants'); + }, + [advance] + ); + + const setOnlineEval = useCallback( + (onlineEval: string) => { + setConfig(c => ({ ...c, onlineEval })); + advance('onlineEval'); + }, + [advance] + ); + + // Target-based mode setters + + const setControlTarget = useCallback( + (target: TargetInfo, isNew: boolean) => { + setConfig(c => ({ + ...c, + controlTargetInfo: target, + controlTargetIsNew: isNew, + controlTarget: target.name, + controlEndpoint: target.qualifier, + runtime: target.runtimeRef, + })); + advance('controlTarget'); + }, + [advance] + ); + + const setTreatmentTarget = useCallback( + (target: TargetInfo, isNew: boolean) => { + setConfig(c => ({ + ...c, + treatmentTargetInfo: target, + treatmentTargetIsNew: isNew, + treatmentTarget: target.name, + treatmentEndpoint: target.qualifier, + // Keep runtime from control if already set, otherwise use treatment's + runtime: c.runtime || target.runtimeRef, + })); + advance('treatmentTarget'); + }, + [advance] + ); + + const setWeights = useCallback( + (controlWeight: number, treatmentWeight: number) => { + setConfig(c => ({ ...c, controlWeight, treatmentWeight })); + advance('weights'); + }, + [advance] + ); + + const setEvalPath = useCallback( + (path: 'select' | 'create') => { + if (path === 'select') { + advance('evalPath'); + } else { + // Skip evalSelect, go to evalCreate + setStep('evalCreate'); + } + }, + [advance] + ); + + const setEvalSelect = useCallback( + (controlEval: string, treatmentEval: string) => { + setConfig(c => ({ ...c, controlOnlineEval: controlEval, treatmentOnlineEval: treatmentEval })); + advance('evalSelect'); + }, + [advance] + ); + + const setEvaluators = useCallback( + (evaluators: string[]) => { + setConfig(c => ({ ...c, evaluators })); + advance('evalCreate'); + }, + [advance] + ); + + const setSamplingRate = useCallback( + (samplingRate: number) => { + setConfig(c => ({ ...c, samplingRate })); + advance('evalSamplingRate'); + }, + [advance] + ); + + const setMaxDuration = useCallback( + (maxDuration: number | undefined) => { + setConfig(c => ({ ...c, maxDuration })); + advance('maxDuration'); + }, + [advance] + ); + + const setEnableOnCreate = useCallback( + (enableOnCreate: boolean) => { + setConfig(c => ({ ...c, enableOnCreate })); + advance('enableOnCreate'); + }, + [advance] + ); + + const reset = useCallback(() => { + setConfig(getDefaultConfig()); + setStep('mode'); + }, []); + + return { + config, + step, + steps: getSteps(), + currentIndex, + goBack, + setSkipCheck, + setMode, + setName, + setDescription, + setAgent, + setGateway, + setGatewayWithName, + setVariants, + setOnlineEval, + setControlTarget, + setTreatmentTarget, + setWeights, + setEvalPath, + setEvalSelect, + setEvaluators, + setSamplingRate, + setMaxDuration, + setEnableOnCreate, + reset, + }; +} diff --git a/src/cli/tui/screens/ab-test/useTargetBasedWizard.ts b/src/cli/tui/screens/ab-test/useTargetBasedWizard.ts new file mode 100644 index 000000000..7c26474d8 --- /dev/null +++ b/src/cli/tui/screens/ab-test/useTargetBasedWizard.ts @@ -0,0 +1,188 @@ +import type { AddABTestConfig, GatewayChoice, TargetInfo } from './types'; +import { useCallback, useState } from 'react'; + +export type TargetBasedStep = 'nameDescription' | 'gateway' | 'builder' | 'enableOnCreate' | 'confirm'; + +export const TARGET_BASED_STEP_LABELS: Record = { + nameDescription: 'Name', + gateway: 'Gateway', + builder: 'Configure', + enableOnCreate: 'Enable', + confirm: 'Confirm', +}; + +const STEPS: TargetBasedStep[] = ['nameDescription', 'gateway', 'builder', 'enableOnCreate', 'confirm']; + +interface TargetBasedConfig { + name: string; + description: string; + gateway: string; + gatewayIsNew: boolean; + controlTargetInfo: TargetInfo | null; + controlTargetIsNew: boolean; + controlWeight: number; + controlOnlineEval: string; + treatmentTargetInfo: TargetInfo | null; + treatmentTargetIsNew: boolean; + treatmentWeight: number; + treatmentOnlineEval: string; + enableOnCreate: boolean; +} + +function getDefaultConfig(): TargetBasedConfig { + return { + name: '', + description: '', + gateway: '', + gatewayIsNew: false, + controlTargetInfo: null, + controlTargetIsNew: false, + controlWeight: 90, + controlOnlineEval: '', + treatmentTargetInfo: null, + treatmentTargetIsNew: false, + treatmentWeight: 10, + treatmentOnlineEval: '', + enableOnCreate: true, + }; +} + +export function useTargetBasedWizard() { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState('nameDescription'); + + const currentIndex = STEPS.indexOf(step); + + const goBack = useCallback(() => { + const idx = STEPS.indexOf(step); + if (idx > 0) { + setStep(STEPS[idx - 1]!); + } + }, [step]); + + const advance = useCallback(() => { + const idx = STEPS.indexOf(step); + if (idx < STEPS.length - 1) { + setStep(STEPS[idx + 1]!); + } + }, [step]); + + const setName = useCallback((name: string) => { + setConfig(c => ({ ...c, name })); + }, []); + + const setDescription = useCallback((description: string) => { + setConfig(c => ({ ...c, description })); + }, []); + + const advanceFromNameDescription = useCallback(() => { + setStep('gateway'); + }, []); + + const setGateway = useCallback((name: string, isNew: boolean) => { + setConfig(c => ({ ...c, gateway: name, gatewayIsNew: isNew })); + // Auto-advance to builder + setStep('builder'); + }, []); + + const setControlTarget = useCallback((target: TargetInfo, isNew: boolean) => { + setConfig(c => ({ + ...c, + controlTargetInfo: target, + controlTargetIsNew: isNew, + })); + }, []); + + const setTreatmentTarget = useCallback((target: TargetInfo, isNew: boolean) => { + setConfig(c => ({ + ...c, + treatmentTargetInfo: target, + treatmentTargetIsNew: isNew, + })); + }, []); + + const setControlWeight = useCallback((w: number) => { + setConfig(c => ({ ...c, controlWeight: w, treatmentWeight: 100 - w })); + }, []); + + const setControlEval = useCallback((name: string) => { + setConfig(c => ({ ...c, controlOnlineEval: name })); + }, []); + + const setTreatmentEval = useCallback((name: string) => { + setConfig(c => ({ ...c, treatmentOnlineEval: name })); + }, []); + + const setEnableOnCreate = useCallback((enableOnCreate: boolean) => { + setConfig(c => ({ ...c, enableOnCreate })); + setStep('confirm'); + }, []); + + const isBuilderComplete = + config.controlTargetInfo !== null && + config.treatmentTargetInfo !== null && + config.controlWeight > 0 && + config.treatmentWeight > 0; + + const toAddABTestConfig = useCallback((): AddABTestConfig => { + const gatewayChoice: GatewayChoice = config.gatewayIsNew + ? { type: 'create-new' } + : { type: 'existing-http', name: config.gateway }; + + return { + mode: 'target-based', + name: config.name, + description: config.description, + agent: '', + gatewayChoice, + // Config-bundle fields (safe defaults) + controlBundle: '', + controlVersion: '', + treatmentBundle: '', + treatmentVersion: '', + treatmentWeight: config.treatmentWeight, + onlineEval: '', + // Target-based fields + gateway: config.gateway, + gatewayIsNew: config.gatewayIsNew, + controlTargetInfo: config.controlTargetInfo, + controlTargetIsNew: config.controlTargetIsNew, + treatmentTargetInfo: config.treatmentTargetInfo, + treatmentTargetIsNew: config.treatmentTargetIsNew, + // Legacy target-based fields + runtime: config.controlTargetInfo?.runtimeRef ?? '', + controlTarget: config.controlTargetInfo?.name ?? '', + controlEndpoint: config.controlTargetInfo?.qualifier ?? '', + treatmentTarget: config.treatmentTargetInfo?.name ?? '', + treatmentEndpoint: config.treatmentTargetInfo?.qualifier ?? '', + controlWeight: config.controlWeight, + controlOnlineEval: config.controlOnlineEval, + treatmentOnlineEval: config.treatmentOnlineEval, + evaluators: [], + samplingRate: 10, + maxDuration: undefined, + enableOnCreate: config.enableOnCreate, + }; + }, [config]); + + return { + config, + step, + steps: STEPS, + currentIndex, + goBack, + advance, + setName, + setDescription, + advanceFromNameDescription, + setGateway, + setControlTarget, + setTreatmentTarget, + setControlWeight, + setControlEval, + setTreatmentEval, + setEnableOnCreate, + isBuilderComplete, + toAddABTestConfig, + }; +} diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index ac2e8e40b..eef7f4db2 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -3,10 +3,12 @@ import { VPC_ENDPOINT_WARNING } from '../../../commands/shared/vpc-utils'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; import { ErrorPrompt } from '../../components'; import { useAvailableAgents } from '../../hooks/useCreateMcp'; +import { AddABTestFlow } from '../ab-test'; import { AddAgentFlow } from '../agent/AddAgentFlow'; import type { AddAgentConfig } from '../agent/types'; import { FRAMEWORK_OPTIONS } from '../agent/types'; import { useAddAgent } from '../agent/useAddAgent'; +import { AddConfigBundleFlow } from '../config-bundle'; import { AddEvaluatorFlow } from '../evaluator'; import { AddIdentityFlow } from '../identity'; import { AddGatewayFlow, AddGatewayTargetFlow } from '../mcp'; @@ -31,6 +33,8 @@ type FlowState = | { name: 'evaluator-wizard' } | { name: 'online-eval-wizard' } | { name: 'policy-wizard' } + | { name: 'config-bundle-wizard' } + | { name: 'ab-test-wizard' } | { name: 'runtime-endpoint-wizard' } | { name: 'agent-create-success'; @@ -183,6 +187,10 @@ function getInitialFlowState(resource?: AddResourceType): FlowState { return { name: 'policy-wizard' }; case 'runtime-endpoint': return { name: 'runtime-endpoint-wizard' }; + case 'config-bundle': + return { name: 'config-bundle-wizard' }; + case 'ab-test': + return { name: 'ab-test-wizard' }; default: return { name: 'select' }; } @@ -230,6 +238,12 @@ export function AddFlow(props: AddFlowProps) { case 'policy': setFlow({ name: 'policy-wizard' }); break; + case 'config-bundle': + setFlow({ name: 'config-bundle-wizard' }); + break; + case 'ab-test': + setFlow({ name: 'ab-test-wizard' }); + break; case 'runtime-endpoint': setFlow({ name: 'runtime-endpoint-wizard' }); break; @@ -466,6 +480,32 @@ export function AddFlow(props: AddFlowProps) { ); } + // Configuration bundle wizard + if (flow.name === 'config-bundle-wizard') { + return ( + setFlow({ name: 'select' })} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + + // AB test wizard + if (flow.name === 'ab-test-wizard') { + return ( + setFlow({ name: 'select' })} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + if (flow.name === 'runtime-endpoint-wizard') { return ( ({ diff --git a/src/cli/tui/screens/agent/AddAgentScreen.tsx b/src/cli/tui/screens/agent/AddAgentScreen.tsx index e99386055..c8961c065 100644 --- a/src/cli/tui/screens/agent/AddAgentScreen.tsx +++ b/src/cli/tui/screens/agent/AddAgentScreen.tsx @@ -185,6 +185,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg idleTimeout: '' as string, maxLifetime: '' as string, sessionStorageMountPath: '' as string, + withConfigBundle: undefined as boolean | undefined, }); const [byoAdvancedSettings, setByoAdvancedSettings] = useState>(new Set()); const [byoAuthorizerType, setByoAuthorizerType] = useState('AWS_IAM'); @@ -311,6 +312,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg idleRuntimeSessionTimeout: generateWizard.config.idleRuntimeSessionTimeout, maxLifetime: generateWizard.config.maxLifetime, sessionStorageMountPath: generateWizard.config.sessionStorageMountPath, + withConfigBundle: generateWizard.config.withConfigBundle, pythonVersion: DEFAULT_PYTHON_VERSION, memory: generateWizard.config.memory, }; @@ -433,6 +435,7 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg ...(byoConfig.idleTimeout && { idleRuntimeSessionTimeout: Number(byoConfig.idleTimeout) }), ...(byoConfig.maxLifetime && { maxLifetime: Number(byoConfig.maxLifetime) }), ...(byoConfig.sessionStorageMountPath && { sessionStorageMountPath: byoConfig.sessionStorageMountPath }), + ...(byoConfig.withConfigBundle && { withConfigBundle: true }), pythonVersion: DEFAULT_PYTHON_VERSION, memory: 'none', }; @@ -494,11 +497,14 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg idleTimeout: '', maxLifetime: '', sessionStorageMountPath: '', + withConfigBundle: undefined, })); setByoAuthorizerType('AWS_IAM'); setByoJwtConfig(undefined); setByoStep('confirm'); } else { + // Config bundle has no sub-steps — set flag immediately + setByoConfig(c => ({ ...c, withConfigBundle: selected.has('configBundle') || undefined })); // Navigate to first advanced sub-step (steps memo hasn't updated yet) setTimeout(() => { if (selected.has('dockerfile') && byoConfig.buildType === 'Container') { @@ -1348,6 +1354,9 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg ...(byoConfig.sessionStorageMountPath ? [{ label: 'Session Storage', value: byoConfig.sessionStorageMountPath }] : []), + ...(byoConfig.withConfigBundle + ? [{ label: 'Config Bundle', value: 'Yes (auto-created on deploy)' }] + : []), ]} /> )} diff --git a/src/cli/tui/screens/agent/types.ts b/src/cli/tui/screens/agent/types.ts index bc1553352..c708bcac0 100644 --- a/src/cli/tui/screens/agent/types.ts +++ b/src/cli/tui/screens/agent/types.ts @@ -97,6 +97,8 @@ export interface AddAgentConfig { maxLifetime?: number; /** Mount path for session filesystem storage (e.g. /mnt/session-storage) */ sessionStorageMountPath?: string; + /** When true, create a config bundle wired into the agent template */ + withConfigBundle?: boolean; /** Python version (only for Python agents) */ pythonVersion: PythonRuntime; /** Memory option (create path only) */ diff --git a/src/cli/tui/screens/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index 3160e8712..e2fabe140 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -1,7 +1,7 @@ import { APP_DIR, ConfigIO, NoProjectError, findConfigRoot, setEnvVar } from '../../../../lib'; import type { AgentEnvSpec, DirectoryPath, FilePath } from '../../../../schema'; -import { getErrorMessage } from '../../../errors'; import { type PythonSetupResult, setupPythonProject } from '../../../operations'; +import { createConfigBundleForAgent } from '../../../operations/agent/config-bundle-defaults'; import { mapGenerateConfigToRenderConfig, mapModelProviderToCredentials, @@ -12,6 +12,19 @@ import { executeImportAgent } from '../../../operations/agent/import'; import { buildAuthorizerConfigFromJwtConfig, createManagedOAuthCredential } from '../../../primitives/auth-utils'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; import { credentialPrimitive } from '../../../primitives/registry'; +import { withAddTelemetry } from '../../../telemetry/cli-command-run.js'; +import { + AgentType as AgentTypeEnum, + AuthorizerType as AuthorizerTypeEnum, + Build, + Framework, + Language, + Memory as MemoryEnum, + ModelProvider, + NetworkMode, + Protocol, + standardize, +} from '../../../telemetry/schemas/common-shapes.js'; import { createRenderer } from '../../../templates'; import type { GenerateConfig } from '../generate/types'; import type { AddAgentConfig } from './types'; @@ -118,6 +131,7 @@ function mapAddAgentConfigToGenerateConfig(config: AddAgentConfig): GenerateConf idleRuntimeSessionTimeout: config.idleRuntimeSessionTimeout, maxLifetime: config.maxLifetime, sessionStorageMountPath: config.sessionStorageMountPath, + withConfigBundle: config.withConfigBundle, }; } @@ -135,34 +149,25 @@ export function useAddAgent() { const addAgent = useCallback(async (config: AddAgentConfig): Promise => { setIsLoading(true); try { - const configBaseDir = findConfigRoot(); - if (!configBaseDir) { - return { ok: false, error: new NoProjectError().message }; - } - - const configIO = new ConfigIO({ baseDir: configBaseDir }); - - if (!configIO.configExists('project')) { - return { ok: false, error: new NoProjectError().message }; - } - - // Check for duplicate agent name - const project = await configIO.readProjectSpec(); - const existingAgent = project.runtimes.find(agent => agent.name === config.name); - if (existingAgent) { - return { ok: false, error: `Agent "${config.name}" already exists in this project.` }; - } - - // Branch based on agent type - if (config.agentType === 'import') { - return await handleImportPath(config, configBaseDir); - } else if (config.agentType === 'create') { - return await handleCreatePath(config, configBaseDir); - } else { - return await handleByoPath(config, configIO, configBaseDir); + const result = await withAddTelemetry( + 'add.agent', + { + language: standardize(Language, config.language), + framework: standardize(Framework, config.framework), + model_provider: standardize(ModelProvider, config.modelProvider), + agent_type: standardize(AgentTypeEnum, config.agentType), + build: standardize(Build, config.buildType), + protocol: standardize(Protocol, config.protocol ?? 'HTTP'), + network_mode: standardize(NetworkMode, config.networkMode ?? 'PUBLIC'), + authorizer_type: standardize(AuthorizerTypeEnum, config.authorizerType ?? 'NONE'), + memory: standardize(MemoryEnum, config.memory ?? 'none'), + }, + () => addAgentInner(config) + ); + if (!result.success) { + return { ok: false, error: result.error }; } - } catch (err) { - return { ok: false, error: getErrorMessage(err) }; + return result.outcome; } finally { setIsLoading(false); } @@ -175,6 +180,43 @@ export function useAddAgent() { return { addAgent, isLoading, reset }; } +type AddAgentInnerResult = + | { success: true; outcome: AddAgentCreateResult | AddAgentByoResult } + | { success: false; error: string }; + +async function addAgentInner(config: AddAgentConfig): Promise { + const configBaseDir = findConfigRoot(); + if (!configBaseDir) { + return { success: false, error: new NoProjectError().message }; + } + + const configIO = new ConfigIO({ baseDir: configBaseDir }); + + if (!configIO.configExists('project')) { + return { success: false, error: new NoProjectError().message }; + } + + const project = await configIO.readProjectSpec(); + const existingAgent = project.runtimes.find(agent => agent.name === config.name); + if (existingAgent) { + return { success: false, error: `Agent "${config.name}" already exists in this project.` }; + } + + let outcome: AddAgentCreateResult | AddAgentByoResult | AddAgentError; + if (config.agentType === 'import') { + outcome = await handleImportPath(config, configBaseDir); + } else if (config.agentType === 'create') { + outcome = await handleCreatePath(config, configBaseDir); + } else { + outcome = await handleByoPath(config, configIO, configBaseDir); + } + + if (!outcome.ok) { + return { success: false, error: outcome.error }; + } + return { success: true, outcome }; +} + /** * Handle the "create" path: generate agent from template and write to project. */ @@ -259,6 +301,11 @@ async function handleCreatePath( pythonSetupResult = await setupPythonProject({ projectDir: agentPath }); } + // Auto-create config bundle when opted in + if (config.withConfigBundle) { + await createConfigBundleForAgent(config.name, configBaseDir); + } + return { ok: true, type: 'create', diff --git a/src/cli/tui/screens/config-bundle-hub/ConfigBundleFlow.tsx b/src/cli/tui/screens/config-bundle-hub/ConfigBundleFlow.tsx new file mode 100644 index 000000000..4c634ca37 --- /dev/null +++ b/src/cli/tui/screens/config-bundle-hub/ConfigBundleFlow.tsx @@ -0,0 +1,60 @@ +/** + * Config Bundle Flow — manages navigation between hub, version history, and diff screens. + */ +import { ConfigBundleHubScreen } from './ConfigBundleHubScreen'; +import { DiffScreen } from './DiffScreen'; +import { VersionHistoryScreen } from './VersionHistoryScreen'; +import type { BundleWithMeta } from './useConfigBundleHub'; +import React, { useState } from 'react'; + +type FlowState = + | { name: 'hub' } + | { name: 'versions'; bundle: BundleWithMeta; region: string } + | { name: 'diff'; bundle: BundleWithMeta; region: string; fromVersionId: string; toVersionId: string }; + +interface ConfigBundleFlowProps { + onExit: () => void; +} + +export function ConfigBundleFlow({ onExit }: ConfigBundleFlowProps) { + const [flow, setFlow] = useState({ name: 'hub' }); + + if (flow.name === 'hub') { + return ( + { + setFlow({ name: 'versions', bundle, region }); + }} + onExit={onExit} + /> + ); + } + + if (flow.name === 'versions') { + return ( + + setFlow({ name: 'diff', bundle: flow.bundle, region: flow.region, fromVersionId, toVersionId }) + } + onExit={() => setFlow({ name: 'hub' })} + /> + ); + } + + if (flow.name === 'diff') { + return ( + setFlow({ name: 'versions', bundle: flow.bundle, region: flow.region })} + /> + ); + } + + return null; +} diff --git a/src/cli/tui/screens/config-bundle-hub/ConfigBundleHubScreen.tsx b/src/cli/tui/screens/config-bundle-hub/ConfigBundleHubScreen.tsx new file mode 100644 index 000000000..476336dd0 --- /dev/null +++ b/src/cli/tui/screens/config-bundle-hub/ConfigBundleHubScreen.tsx @@ -0,0 +1,129 @@ +/** + * Top-level config bundle hub — lists all deployed bundles. + * Enter drills into version history. + */ +import { Panel, Screen } from '../../components'; +import type { BundleWithMeta } from './useConfigBundleHub'; +import { useConfigBundleHub } from './useConfigBundleHub'; +import { Box, Text, useInput } from 'ink'; +import React from 'react'; + +function formatRelativeTime(epochSeconds: string): string { + const ms = Number(epochSeconds) < 1e12 ? Number(epochSeconds) * 1000 : Number(epochSeconds); + const diff = Date.now() - ms; + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +interface ConfigBundleHubScreenProps { + onSelectBundle: (bundle: BundleWithMeta, region: string) => void; + onExit: () => void; +} + +export function ConfigBundleHubScreen({ onSelectBundle, onExit }: ConfigBundleHubScreenProps) { + const { bundles, isLoading, error, region } = useConfigBundleHub(); + const [selectedIndex, setSelectedIndex] = React.useState(0); + + useInput( + (input: string, key: { return: boolean; upArrow: boolean; downArrow: boolean }) => { + if (key.upArrow && bundles.length > 0) { + setSelectedIndex(i => (i - 1 + bundles.length) % bundles.length); + } + if (key.downArrow && bundles.length > 0) { + setSelectedIndex(i => (i + 1) % bundles.length); + } + if (key.return && bundles[selectedIndex]) { + onSelectBundle(bundles[selectedIndex], region); + } + }, + { isActive: !isLoading && bundles.length > 0 } + ); + + if (isLoading) { + return ( + + Loading configuration bundles... + + ); + } + + if (error) { + return ( + + Error: {error} + + ); + } + + if (bundles.length === 0) { + return ( + + + No configuration bundles found. + Use `agentcore add config-bundle` to create one, then deploy. + + + ); + } + + const headerContent = ( + + Region: + {region} + · {bundles.length} bundle(s) + + ); + + return ( + + + {bundles.map((bundle, idx) => ( + + ))} + + + ); +} + +function BundleRow({ bundle, selected }: { bundle: BundleWithMeta; selected: boolean }) { + const branchSummary = bundle.branches.length > 0 ? bundle.branches.join(', ') : 'no branches'; + + return ( + + + {selected ? '❯' : ' '} + + {bundle.bundleName} + + + + {' '} + + Versions: {bundle.versionCount} ({branchSummary}) + + + {bundle.description && ( + + {' '} + Description: {bundle.description} + + )} + {bundle.lastUpdated && ( + + {' '} + Last update: {formatRelativeTime(bundle.lastUpdated)} + + )} + + ); +} diff --git a/src/cli/tui/screens/config-bundle-hub/DiffScreen.tsx b/src/cli/tui/screens/config-bundle-hub/DiffScreen.tsx new file mode 100644 index 000000000..f91b2b00e --- /dev/null +++ b/src/cli/tui/screens/config-bundle-hub/DiffScreen.tsx @@ -0,0 +1,149 @@ +/** + * Diff screen — shows component differences between two bundle versions. + */ +import { getConfigurationBundleVersion } from '../../../../cli/aws/agentcore-config-bundles'; +import type { GetConfigurationBundleVersionResult } from '../../../../cli/aws/agentcore-config-bundles'; +import { deepDiff } from '../../../../cli/operations/config-bundle/diff-versions'; +import type { DiffEntry } from '../../../../cli/operations/config-bundle/diff-versions'; +import { Panel, Screen } from '../../components'; +import { Box, Text, useInput, useStdout } from 'ink'; +import React, { useEffect, useMemo, useState } from 'react'; + +function formatTimestamp(epochSeconds: string): string { + const num = Number(epochSeconds); + const ms = num < 1e12 ? num * 1000 : num; + return new Date(ms) + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, 'Z'); +} + +interface DiffScreenProps { + bundleId: string; + bundleName: string; + fromVersionId: string; + toVersionId: string; + region: string; + onExit: () => void; +} + +export function DiffScreen({ bundleId, bundleName, fromVersionId, toVersionId, region, onExit }: DiffScreenProps) { + const [fromVersion, setFromVersion] = useState(); + const [toVersion, setToVersion] = useState(); + const [diffs, setDiffs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + const [scrollOffset, setScrollOffset] = useState(0); + const { stdout } = useStdout(); + + useEffect(() => { + async function load() { + try { + const [from, to] = await Promise.all([ + getConfigurationBundleVersion({ region, bundleId, versionId: fromVersionId }), + getConfigurationBundleVersion({ region, bundleId, versionId: toVersionId }), + ]); + setFromVersion(from); + setToVersion(to); + setDiffs(deepDiff(from.components, to.components)); + setIsLoading(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setIsLoading(false); + } + } + void load(); + }, [bundleId, fromVersionId, toVersionId, region]); + + // Build display lines + const lines = useMemo(() => { + if (!fromVersion || !toVersion) return []; + const result: { text: string; color?: string }[] = []; + + result.push({ + text: `Diff: ${fromVersion.versionId} → ${toVersion.versionId}`, + }); + result.push({ + text: `From: ${fromVersion.lineageMetadata?.commitMessage ?? '(no message)'} (${formatTimestamp(fromVersion.versionCreatedAt)})`, + color: 'gray', + }); + result.push({ + text: `To: ${toVersion.lineageMetadata?.commitMessage ?? '(no message)'} (${formatTimestamp(toVersion.versionCreatedAt)})`, + color: 'gray', + }); + result.push({ text: '' }); + + if (diffs.length === 0) { + result.push({ text: 'No differences found.', color: 'green' }); + } else { + result.push({ text: `${diffs.length} change(s):` }); + result.push({ text: '' }); + + for (const d of diffs) { + result.push({ text: d.path }); + if (d.type === 'added') { + result.push({ text: `+ ${JSON.stringify(d.newValue)}`, color: 'green' }); + } else if (d.type === 'removed') { + result.push({ text: `- ${JSON.stringify(d.oldValue)}`, color: 'red' }); + } else if (d.type === 'changed') { + result.push({ text: `- ${JSON.stringify(d.oldValue)}`, color: 'red' }); + result.push({ text: `+ ${JSON.stringify(d.newValue)}`, color: 'green' }); + } + result.push({ text: '' }); + } + } + + return result; + }, [fromVersion, toVersion, diffs]); + + const terminalHeight = stdout?.rows ?? 24; + const displayHeight = Math.max(5, terminalHeight - 10); + const maxScroll = Math.max(0, lines.length - displayHeight); + + useInput((_input, key) => { + if (key.upArrow) setScrollOffset(prev => Math.max(0, prev - 1)); + if (key.downArrow) setScrollOffset(prev => Math.min(maxScroll, prev + 1)); + }); + + if (isLoading) { + return ( + + Loading versions for diff... + + ); + } + + if (error) { + return ( + + Error: {error} + + ); + } + + const visibleLines = lines.slice(scrollOffset, scrollOffset + displayHeight); + const needsScroll = lines.length > displayHeight; + + return ( + + + + {visibleLines.map((line, idx) => ( + + {line.text} + + ))} + + {needsScroll && ( + + [{scrollOffset + 1}-{Math.min(scrollOffset + displayHeight, lines.length)} of {lines.length}] + + )} + + + ); +} diff --git a/src/cli/tui/screens/config-bundle-hub/VersionHistoryScreen.tsx b/src/cli/tui/screens/config-bundle-hub/VersionHistoryScreen.tsx new file mode 100644 index 000000000..56e161c85 --- /dev/null +++ b/src/cli/tui/screens/config-bundle-hub/VersionHistoryScreen.tsx @@ -0,0 +1,245 @@ +/** + * Version history screen — shows versions grouped by branch for a single bundle. + * Enter views version details, D starts diff selection. + */ +import { getConfigurationBundleVersion } from '../../../../cli/aws/agentcore-config-bundles'; +import type { ConfigurationBundleVersionSummary } from '../../../../cli/aws/agentcore-config-bundles'; +import { Panel, Screen } from '../../components'; +import type { BundleWithMeta } from './useConfigBundleHub'; +import { useVersionHistory } from './useConfigBundleHub'; +import { Box, Text, useInput } from 'ink'; +import React, { useMemo, useState } from 'react'; + +function formatTimestamp(epochSeconds: string): string { + const num = Number(epochSeconds); + const ms = num < 1e12 ? num * 1000 : num; + return new Date(ms) + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, 'Z'); +} + +interface VersionHistoryScreenProps { + bundle: BundleWithMeta; + region: string; + onViewDiff: (bundleId: string, fromVersionId: string, toVersionId: string) => void; + onExit: () => void; +} + +type Mode = 'browse' | 'diff-select-from' | 'diff-select-to' | 'version-detail'; + +export function VersionHistoryScreen({ bundle, region, onViewDiff, onExit }: VersionHistoryScreenProps) { + const { versions, isLoading, error } = useVersionHistory(bundle.bundleId, region); + const [selectedIndex, setSelectedIndex] = useState(0); + const [mode, setMode] = useState('browse'); + const [diffFromId, setDiffFromId] = useState(); + const [detailText, setDetailText] = useState(); + + // Flat list of all versions for navigation + const flatVersions = useMemo(() => versions, [versions]); + + // Group by branch for display + const byBranch = useMemo(() => { + const map = new Map(); + for (const v of versions) { + const branch = v.lineageMetadata?.branchName ?? 'unknown'; + if (!map.has(branch)) map.set(branch, []); + map.get(branch)!.push(v); + } + return map; + }, [versions]); + + useInput( + (input, key) => { + if (isLoading || flatVersions.length === 0) return; + + if (mode === 'version-detail') { + if (key.escape) setMode('browse'); + return; + } + + // Navigation + if (key.upArrow) { + setSelectedIndex(i => (i - 1 + flatVersions.length) % flatVersions.length); + return; + } + if (key.downArrow) { + setSelectedIndex(i => (i + 1) % flatVersions.length); + return; + } + + if (mode === 'browse') { + // Enter — view version detail + if (key.return && flatVersions[selectedIndex]) { + setMode('version-detail'); + setDetailText(undefined); + void loadDetail(flatVersions[selectedIndex].versionId); + return; + } + // D — start diff + if (input === 'd' || input === 'D') { + setMode('diff-select-from'); + return; + } + } + + if (mode === 'diff-select-from') { + if (key.escape) { + setMode('browse'); + return; + } + if (key.return && flatVersions[selectedIndex]) { + setDiffFromId(flatVersions[selectedIndex].versionId); + setMode('diff-select-to'); + return; + } + } + + if (mode === 'diff-select-to') { + if (key.escape) { + setMode('diff-select-from'); + return; + } + if (key.return && flatVersions[selectedIndex] && diffFromId) { + onViewDiff(bundle.bundleId, diffFromId, flatVersions[selectedIndex].versionId); + return; + } + } + }, + { isActive: !isLoading } + ); + + async function loadDetail(versionId: string) { + try { + const detail = await getConfigurationBundleVersion({ + region, + bundleId: bundle.bundleId, + versionId, + }); + const lines: string[] = []; + lines.push(`Version: ${detail.versionId}`); + if (detail.description) lines.push(`Description: ${detail.description}`); + if (detail.lineageMetadata?.branchName) lines.push(`Branch: ${detail.lineageMetadata.branchName}`); + if (detail.lineageMetadata?.commitMessage) lines.push(`Message: ${detail.lineageMetadata.commitMessage}`); + if (detail.lineageMetadata?.createdBy) { + const cb = detail.lineageMetadata.createdBy; + lines.push(`Created by: ${cb.name}${cb.arn ? ` (${cb.arn})` : ''}`); + } + if (detail.lineageMetadata?.parentVersionIds?.length) { + lines.push(`Parent: ${detail.lineageMetadata.parentVersionIds.map(id => id).join(', ')}`); + } + lines.push(`Created: ${formatTimestamp(detail.versionCreatedAt)}`); + lines.push(''); + lines.push('Components:'); + for (const [arn, comp] of Object.entries(detail.components)) { + lines.push(` ${arn}`); + lines.push(` ${JSON.stringify(comp.configuration, null, 2).split('\n').join('\n ')}`); + lines.push(''); + } + setDetailText(lines.join('\n')); + } catch (err) { + setDetailText(`Error loading version: ${err instanceof Error ? err.message : String(err)}`); + } + } + + if (isLoading) { + return ( + + Loading version history... + + ); + } + + if (error) { + return ( + + Error: {error} + + ); + } + + // Version detail overlay + if (mode === 'version-detail') { + return ( + setMode('browse')} helpText="Esc back"> + {detailText ? {detailText} : Loading...} + + ); + } + + // Mode-specific help text + let helpText = '↑↓ navigate · Enter view · D diff · Esc back · Ctrl+C quit'; + if (mode === 'diff-select-from') { + helpText = '↑↓ navigate · Enter select FROM version · Esc cancel'; + } else if (mode === 'diff-select-to') { + helpText = `↑↓ navigate · Enter select TO version · Esc back (from: ${diffFromId!})`; + } + + // Mode-specific header + let modeIndicator: React.ReactNode = null; + if (mode === 'diff-select-from') { + modeIndicator = ( + + Select the FROM version for diff: + + ); + } else if (mode === 'diff-select-to') { + modeIndicator = ( + + From: {diffFromId!} — Now select the TO version: + + ); + } + + // Build a flat index map so we can highlight the selected version + let flatIdx = 0; + + return ( + + + {modeIndicator} + {[...byBranch.entries()].map(([branch, branchVersions]) => ( + + + Branch: {branch} + + {branchVersions.map((v, i) => { + const currentFlatIdx = flatIdx++; + const isSelected = currentFlatIdx === selectedIndex; + const meta = v.lineageMetadata; + const message = meta?.commitMessage ?? ''; + const isLast = i === branchVersions.length - 1; + const connector = isLast ? '└' : '├'; + const isDiffFrom = v.versionId === diffFromId; + + return ( + + + {isSelected ? '❯' : ' '} + {connector} + + {v.versionId} + + {formatTimestamp(v.versionCreatedAt)} + {message ? "{message}" : null} + + {meta?.parentVersionIds?.length ? ( + + {' '} + {isLast ? ' ' : '│'} parent: {meta.parentVersionIds.join(', ')} + + ) : null} + + ); + })} + + ))} + + + ); +} diff --git a/src/cli/tui/screens/config-bundle-hub/index.ts b/src/cli/tui/screens/config-bundle-hub/index.ts new file mode 100644 index 000000000..b7ceb3d02 --- /dev/null +++ b/src/cli/tui/screens/config-bundle-hub/index.ts @@ -0,0 +1,4 @@ +export { ConfigBundleFlow } from './ConfigBundleFlow'; +export { ConfigBundleHubScreen } from './ConfigBundleHubScreen'; +export { VersionHistoryScreen } from './VersionHistoryScreen'; +export { DiffScreen } from './DiffScreen'; diff --git a/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts new file mode 100644 index 000000000..c0276ae53 --- /dev/null +++ b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts @@ -0,0 +1,220 @@ +/** + * Hook for the Config Bundle Hub — reads bundles from project config + * and enriches deployed ones with version metadata from the API. + */ +import type { ConfigurationBundleVersionSummary } from '../../../../cli/aws/agentcore-config-bundles'; +import { + listConfigurationBundleVersions, + listConfigurationBundles, +} from '../../../../cli/aws/agentcore-config-bundles'; +import { ConfigIO } from '../../../../lib'; +import { getBundleNameVariants } from '../../../operations/config-bundle/bundle-name-variants'; +import { useEffect, useRef, useState } from 'react'; + +export interface BundleWithMeta { + bundleId: string; + bundleArn: string; + bundleName: string; + description?: string; + versionCount: number; + branches: string[]; + lastUpdated?: string; +} + +export interface ConfigBundleHubState { + bundles: BundleWithMeta[]; + isLoading: boolean; + error?: string; + region: string; +} + +export function useConfigBundleHub(): ConfigBundleHubState { + const [bundles, setBundles] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + const [region, setRegion] = useState('us-east-1'); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + + async function load() { + setIsLoading(true); + setError(undefined); + try { + const configIO = new ConfigIO(); + const [projectSpec, deployedState, targets] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readDeployedState(), + configIO.resolveAWSDeploymentTargets(), + ]); + + if (targets.length === 0) { + if (mountedRef.current) { + setError('No AWS deployment targets configured.'); + setIsLoading(false); + } + return; + } + const resolvedRegion = targets[0]!.region; + if (mountedRef.current) setRegion(resolvedRegion); + + // Get config bundles from project config (agentcore.json) + const projectBundles = projectSpec.configBundles ?? []; + if (projectBundles.length === 0) { + if (mountedRef.current) { + setBundles([]); + setIsLoading(false); + } + return; + } + + // Get deployed state to look up bundleIds + const deployedBundles = + Object.values(deployedState.targets).find(t => t.resources?.configBundles)?.resources?.configBundles ?? {}; + + // Build bundle list from project config, enriching with deployed version info + const enriched = await Promise.all( + projectBundles.map(async (bundleSpec): Promise => { + const deployed = deployedBundles[bundleSpec.name]; + if (!deployed) { + // Not yet deployed — show from project config only + return { + bundleId: '', + bundleArn: '', + bundleName: bundleSpec.name, + description: bundleSpec.description, + versionCount: 0, + branches: bundleSpec.branchName ? [bundleSpec.branchName] : [], + }; + } + + // Deployed — fetch version metadata from API + // Use a helper that falls back to the list API if the deployed-state bundleId is stale + let effectiveBundleId = deployed.bundleId; + let effectiveBundleArn = deployed.bundleArn; + + try { + const versions = await listConfigurationBundleVersions({ + region: resolvedRegion, + bundleId: effectiveBundleId, + maxResults: 50, + }); + const branchSet = new Set(); + let latestTs = ''; + for (const v of versions.versions) { + if (v.lineageMetadata?.branchName) branchSet.add(v.lineageMetadata.branchName); + if (v.versionCreatedAt > latestTs) latestTs = v.versionCreatedAt; + } + return { + bundleId: effectiveBundleId, + bundleArn: effectiveBundleArn, + bundleName: bundleSpec.name, + description: bundleSpec.description, + versionCount: versions.versions.length, + branches: [...branchSet], + lastUpdated: latestTs || undefined, + }; + } catch { + // Stale deployed-state ID — try to resolve via list API + try { + const allBundles = await listConfigurationBundles({ region: resolvedRegion, maxResults: 100 }); + const nameVariants = getBundleNameVariants(bundleSpec.name, projectSpec.name); + const match = allBundles.bundles.find(b => nameVariants.includes(b.bundleName)); + if (match) { + effectiveBundleId = match.bundleId; + effectiveBundleArn = match.bundleArn; + const versions = await listConfigurationBundleVersions({ + region: resolvedRegion, + bundleId: effectiveBundleId, + maxResults: 50, + }); + const branchSet = new Set(); + let latestTs = ''; + for (const v of versions.versions) { + if (v.lineageMetadata?.branchName) branchSet.add(v.lineageMetadata.branchName); + if (v.versionCreatedAt > latestTs) latestTs = v.versionCreatedAt; + } + return { + bundleId: effectiveBundleId, + bundleArn: effectiveBundleArn, + bundleName: bundleSpec.name, + description: bundleSpec.description, + versionCount: versions.versions.length, + branches: [...branchSet], + lastUpdated: latestTs || undefined, + }; + } + } catch { + // Both paths failed + } + return { + bundleId: effectiveBundleId, + bundleArn: effectiveBundleArn, + bundleName: bundleSpec.name, + description: bundleSpec.description, + versionCount: 0, + branches: [], + }; + } + }) + ); + + if (mountedRef.current) { + setBundles(enriched); + setIsLoading(false); + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err.message : String(err)); + setIsLoading(false); + } + } + } + + void load(); + return () => { + mountedRef.current = false; + }; + }, []); + + return { bundles, isLoading, error, region }; +} + +export function useVersionHistory(bundleId: string, region: string) { + const [versions, setVersions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + + useEffect(() => { + async function load() { + setIsLoading(true); + setError(undefined); + try { + const allVersions: ConfigurationBundleVersionSummary[] = []; + let nextToken: string | undefined; + do { + const result = await listConfigurationBundleVersions({ + region, + bundleId, + maxResults: 50, + nextToken, + }); + allVersions.push(...result.versions); + nextToken = result.nextToken; + } while (nextToken); + + allVersions.sort((a, b) => Number(b.versionCreatedAt) - Number(a.versionCreatedAt)); + setVersions(allVersions); + setIsLoading(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setIsLoading(false); + } + } + + void load(); + }, [bundleId, region]); + + return { versions, isLoading, error }; +} diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx new file mode 100644 index 000000000..2ccc9fab2 --- /dev/null +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx @@ -0,0 +1,177 @@ +import { ConfigIO } from '../../../../lib'; +import { ErrorPrompt, GradientText, Screen } from '../../components'; +import { useCreateConfigBundle, useExistingConfigBundleNames } from '../../hooks/useCreateConfigBundle'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddConfigBundleScreen } from './AddConfigBundleScreen'; +import type { AddConfigBundleConfig, DeployedComponent } from './types'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'loading' } + | { name: 'create-wizard'; deployedComponents: DeployedComponent[] } + | { name: 'create-success'; bundleName: string } + | { name: 'error'; message: string }; + +interface AddConfigBundleFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function AddConfigBundleFlow({ + isInteractive = true, + onExit, + onBack, + onDev, + onDeploy, +}: AddConfigBundleFlowProps) { + const { createConfigBundle, reset: resetCreate } = useCreateConfigBundle(); + const { names: existingNames } = useExistingConfigBundleNames(); + const [flow, setFlow] = useState({ name: 'loading' }); + + // Load deployed runtimes/gateways and fill in undeployed ones from project spec + useEffect(() => { + void (async () => { + try { + const configIO = new ConfigIO(); + const components: DeployedComponent[] = []; + const deployedArns = new Set(); + + // 1. Collect deployed components (real ARNs) + try { + const deployedState = await configIO.readDeployedState(); + for (const target of Object.values(deployedState.targets)) { + const runtimes = target.resources?.runtimes; + if (runtimes) { + for (const [name, state] of Object.entries(runtimes)) { + components.push({ name, arn: state.runtimeArn, type: 'runtime' }); + deployedArns.add(name); + } + } + const httpGateways = target.resources?.httpGateways; + if (httpGateways) { + for (const [name, state] of Object.entries(httpGateways)) { + components.push({ name, arn: state.gatewayArn, type: 'gateway' }); + deployedArns.add(name); + } + } + } + } catch { + // No deployed state yet — that's fine, we'll use project spec below + } + + // 2. Add undeployed runtimes/gateways from project spec as placeholders + try { + const projectSpec = await configIO.readProjectSpec(); + for (const rt of projectSpec.runtimes ?? []) { + if (!deployedArns.has(rt.name)) { + components.push({ + name: rt.name, + arn: `{{runtime:${rt.name}}}`, + type: 'runtime', + isPlaceholder: true, + }); + } + } + for (const gw of projectSpec.httpGateways ?? []) { + if (!deployedArns.has(gw.name)) { + components.push({ + name: gw.name, + arn: `{{gateway:${gw.name}}}`, + type: 'gateway', + isPlaceholder: true, + }); + } + } + } catch { + // If we can't read project spec, continue with what we have + } + + setFlow({ name: 'create-wizard', deployedComponents: components }); + } catch { + setFlow({ name: 'create-wizard', deployedComponents: [] }); + } + })(); + }, []); + + useEffect(() => { + if (!isInteractive && flow.name === 'create-success') { + onExit(); + } + }, [isInteractive, flow.name, onExit]); + + const handleCreateComplete = useCallback( + (config: AddConfigBundleConfig) => { + void createConfigBundle({ + name: config.name, + description: config.description || undefined, + components: config.components, + branchName: config.branchName || 'mainline', + commitMessage: config.commitMessage || `Create ${config.name}`, + }).then(result => { + if (result.ok) { + setFlow(prev => { + if (prev.name === 'loading') return prev; + return { name: 'create-success', bundleName: result.bundleName }; + }); + return; + } + setFlow(prev => { + if (prev.name === 'loading') return prev; + return { name: 'error', message: result.error }; + }); + }); + }, + [createConfigBundle] + ); + + if (flow.name === 'loading') { + return ( + + + + ); + } + + if (flow.name === 'create-wizard') { + return ( + + ); + } + + if (flow.name === 'create-success') { + return ( + + ); + } + + return ( + { + resetCreate(); + setFlow(prev => { + if (prev.name === 'loading') return prev; + return { name: 'create-wizard', deployedComponents: [] }; + }); + }} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx new file mode 100644 index 000000000..47d33ddf5 --- /dev/null +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -0,0 +1,275 @@ +import { ConfigBundleNameSchema } from '../../../../schema'; +import type { SelectableItem } from '../../components'; +import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { generateUniqueName } from '../../utils'; +import type { AddConfigBundleConfig, ComponentType, DeployedComponent } from './types'; +import { COMPONENT_TYPE_OPTIONS, CONFIG_BUNDLE_STEP_LABELS } from './types'; +import { useAddConfigBundleWizard } from './useAddConfigBundleWizard'; +import { Box, Text } from 'ink'; +import React, { useMemo } from 'react'; + +interface AddConfigBundleScreenProps { + onComplete: (config: AddConfigBundleConfig) => void; + onExit: () => void; + existingBundleNames: string[]; + deployedComponents: DeployedComponent[]; +} + +function validateConfigJson(value: string): string | true { + try { + const parsed: unknown = JSON.parse(value); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return 'Must be a JSON object with key-value pairs'; + } + return true; + } catch (err) { + if (err instanceof SyntaxError) { + return 'Invalid JSON syntax'; + } + return 'Must be a valid JSON object'; + } +} + +export function AddConfigBundleScreen({ + onComplete, + onExit, + existingBundleNames, + deployedComponents, +}: AddConfigBundleScreenProps) { + const wizard = useAddConfigBundleWizard(); + + const componentTypeItems: SelectableItem[] = useMemo( + () => COMPONENT_TYPE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + // Filter deployed components by selected type + const availableComponents: SelectableItem[] = useMemo(() => { + const filtered = deployedComponents.filter(c => c.type === wizard.config.currentComponentType); + // Exclude already-added ARNs + const existingArns = new Set(Object.keys(wizard.config.components)); + return filtered + .filter(c => !existingArns.has(c.arn)) + .map(c => ({ + id: c.arn, + title: c.name, + description: c.isPlaceholder ? '(not yet deployed — ARN resolved on deploy)' : c.arn, + })); + }, [deployedComponents, wizard.config.currentComponentType, wizard.config.components]); + + const addAnotherItems: SelectableItem[] = useMemo( + () => [ + { id: 'no', title: 'Continue' }, + { id: 'yes', title: 'Add another component' }, + ], + [] + ); + + const isNameStep = wizard.step === 'name'; + const isDescriptionStep = wizard.step === 'description'; + const isComponentTypeStep = wizard.step === 'componentType'; + const isComponentSelectStep = wizard.step === 'componentSelect'; + const isConfigurationStep = wizard.step === 'configuration'; + const isAddAnotherStep = wizard.step === 'addAnother'; + const isBranchNameStep = wizard.step === 'branchName'; + const isCommitMessageStep = wizard.step === 'commitMessage'; + const isConfirmStep = wizard.step === 'confirm'; + + const componentTypeNav = useListNavigation({ + items: componentTypeItems, + onSelect: item => wizard.setComponentType(item.id as ComponentType), + onExit: () => wizard.goBack(), + isActive: isComponentTypeStep, + }); + + const componentSelectNav = useListNavigation({ + items: availableComponents, + onSelect: item => wizard.setSelectedComponent(item.id), + onExit: () => wizard.goBack(), + isActive: isComponentSelectStep, + }); + + const addAnotherNav = useListNavigation({ + items: addAnotherItems, + onSelect: item => { + if (item.id === 'yes') wizard.addAnotherComponent(); + else wizard.doneAddingComponents(); + }, + onExit: () => wizard.goBack(), + isActive: isAddAnotherStep, + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(wizard.config), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + const helpText = + isComponentTypeStep || isComponentSelectStep || isAddAnotherStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ( + + ); + + const componentCount = Object.keys(wizard.config.components).length; + + return ( + + + {isNameStep && ( + !existingBundleNames.includes(value) || 'Bundle name already exists'} + /> + )} + + {isDescriptionStep && ( + wizard.goBack()} + /> + )} + + {isComponentTypeStep && ( + 0 + ? `${componentCount} component(s) added. Select another type or go back to continue.` + : 'Select the type of resource to add to this bundle' + } + items={componentTypeItems} + selectedIndex={componentTypeNav.selectedIndex} + /> + )} + + {isComponentSelectStep && availableComponents.length > 0 && ( + + )} + + {isComponentSelectStep && availableComponents.length === 0 && ( + + + No deployed {wizard.config.currentComponentType === 'runtime' ? 'runtimes' : 'gateways'} found. + + Deploy your resources first with `agentcore deploy`, then try again. + Press Esc to go back. + + )} + + {isConfigurationStep && ( + <> + + + Component: {wizard.config.currentComponentArn} + + Enter the configuration as a JSON object (key-value pairs). + Example: {'{"systemPrompt": "You are a helpful assistant", "temperature": 0.7}'} + + { + const parsed = JSON.parse(value) as Record; + wizard.setConfiguration(parsed); + }} + onCancel={() => wizard.goBack()} + customValidation={validateConfigJson} + /> + + )} + + {isAddAnotherStep && ( + <> + + + {componentCount} component{componentCount !== 1 ? 's' : ''} configured: + + {Object.keys(wizard.config.components).map(arn => ( + + {' '}• {arn} + + ))} + + + + )} + + {isBranchNameStep && ( + wizard.goBack()} + /> + )} + + {isCommitMessageStep && ( + wizard.goBack()} + /> + )} + + {isConfirmStep && ( + ({ + label: ` ${arn.split('/').pop() ?? arn}`, + value: Object.keys(comp.configuration).join(', '), + })), + { label: 'Branch', value: wizard.config.branchName || 'mainline' }, + { label: 'Message', value: wizard.config.commitMessage || `Create ${wizard.config.name}` }, + ]} + /> + )} + + + ); +} diff --git a/src/cli/tui/screens/config-bundle/index.ts b/src/cli/tui/screens/config-bundle/index.ts new file mode 100644 index 000000000..831a3c94e --- /dev/null +++ b/src/cli/tui/screens/config-bundle/index.ts @@ -0,0 +1 @@ +export { AddConfigBundleFlow } from './AddConfigBundleFlow'; diff --git a/src/cli/tui/screens/config-bundle/types.ts b/src/cli/tui/screens/config-bundle/types.ts new file mode 100644 index 000000000..dba1ba4e7 --- /dev/null +++ b/src/cli/tui/screens/config-bundle/types.ts @@ -0,0 +1,57 @@ +import type { ComponentConfigurationMap } from '../../../../schema'; + +// ───────────────────────────────────────────────────────────────────────────── +// Config Bundle Wizard Types +// ───────────────────────────────────────────────────────────────────────────── + +export type AddConfigBundleStep = + | 'name' + | 'description' + | 'componentType' + | 'componentSelect' + | 'configuration' + | 'addAnother' + | 'branchName' + | 'commitMessage' + | 'confirm'; + +export type ComponentType = 'runtime' | 'gateway'; + +export interface DeployedComponent { + name: string; + arn: string; + type: ComponentType; + /** True when the resource is not yet deployed — ARN is a placeholder resolved at deploy time. */ + isPlaceholder?: boolean; +} + +export interface AddConfigBundleConfig { + name: string; + description: string; + components: ComponentConfigurationMap; + /** Raw text entered by user (JSON string or file path). */ + componentsRaw: string; + branchName: string; + commitMessage: string; + /** Currently selected component type in wizard. */ + currentComponentType?: ComponentType; + /** Currently selected component ARN in wizard. */ + currentComponentArn?: string; +} + +export const CONFIG_BUNDLE_STEP_LABELS: Record = { + name: 'Name', + description: 'Description', + componentType: 'Type', + componentSelect: 'Component', + configuration: 'Config', + addAnother: 'More?', + branchName: 'Branch', + commitMessage: 'Message', + confirm: 'Confirm', +}; + +export const COMPONENT_TYPE_OPTIONS = [ + { id: 'runtime', title: 'Agent Runtime', description: 'Configure an agent runtime' }, + { id: 'gateway', title: 'HTTP Gateway', description: 'Configure an HTTP gateway' }, +] as const; diff --git a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts new file mode 100644 index 000000000..daa79173b --- /dev/null +++ b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts @@ -0,0 +1,113 @@ +import type { ComponentConfigurationMap } from '../../../../schema'; +import type { AddConfigBundleConfig, AddConfigBundleStep, ComponentType } from './types'; +import { useCallback, useState } from 'react'; + +const ALL_STEPS: AddConfigBundleStep[] = [ + 'name', + 'description', + 'componentType', + 'componentSelect', + 'configuration', + 'addAnother', + 'branchName', + 'commitMessage', + 'confirm', +]; + +function getDefaultConfig(): AddConfigBundleConfig { + return { + name: '', + description: '', + components: {}, + componentsRaw: '', + branchName: 'mainline', + commitMessage: '', + }; +} + +export function useAddConfigBundleWizard() { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState('name'); + + const currentIndex = ALL_STEPS.indexOf(step); + + const goBack = useCallback(() => { + const prevStep = ALL_STEPS[currentIndex - 1]; + if (prevStep) setStep(prevStep); + }, [currentIndex]); + + const setName = useCallback((name: string) => { + setConfig(c => ({ ...c, name })); + setStep('description'); + }, []); + + const setDescription = useCallback((description: string) => { + setConfig(c => ({ ...c, description })); + setStep('componentType'); + }, []); + + const setComponentType = useCallback((componentType: ComponentType) => { + setConfig(c => ({ ...c, currentComponentType: componentType, currentComponentArn: undefined })); + setStep('componentSelect'); + }, []); + + const setSelectedComponent = useCallback((arn: string) => { + setConfig(c => ({ ...c, currentComponentArn: arn })); + setStep('configuration'); + }, []); + + const setConfiguration = useCallback((configuration: Record) => { + setConfig(c => { + const arn = c.currentComponentArn; + if (!arn) return c; + const updatedComponents: ComponentConfigurationMap = { + ...c.components, + [arn]: { configuration }, + }; + return { ...c, components: updatedComponents }; + }); + setStep('addAnother'); + }, []); + + const addAnotherComponent = useCallback(() => { + setConfig(c => ({ ...c, currentComponentType: undefined, currentComponentArn: undefined })); + setStep('componentType'); + }, []); + + const doneAddingComponents = useCallback(() => { + setStep('branchName'); + }, []); + + const setBranchName = useCallback((branchName: string) => { + setConfig(c => ({ ...c, branchName })); + setStep('commitMessage'); + }, []); + + const setCommitMessage = useCallback((commitMessage: string) => { + setConfig(c => ({ ...c, commitMessage })); + setStep('confirm'); + }, []); + + const reset = useCallback(() => { + setConfig(getDefaultConfig()); + setStep('name'); + }, []); + + return { + config, + step, + steps: ALL_STEPS, + currentIndex, + goBack, + setName, + setDescription, + setComponentType, + setSelectedComponent, + setConfiguration, + addAnotherComponent, + doneAddingComponents, + setBranchName, + setCommitMessage, + reset, + }; +} diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index 04c003ad0..9d1742810 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -3,6 +3,7 @@ import type { DeployedState } from '../../../../schema'; import { getErrorMessage } from '../../../errors'; import { CreateLogger } from '../../../logging'; import { initGitRepo, setupPythonProject, writeEnvFile, writeGitignore } from '../../../operations'; +import { createConfigBundleForAgent } from '../../../operations/agent/config-bundle-defaults'; import { mapGenerateConfigToRenderConfig, mapModelProviderToCredentials, @@ -276,6 +277,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { idleRuntimeSessionTimeout: addAgentConfig.idleRuntimeSessionTimeout, maxLifetime: addAgentConfig.maxLifetime, sessionStorageMountPath: addAgentConfig.sessionStorageMountPath, + withConfigBundle: addAgentConfig.withConfigBundle, }; logger.logSubStep(`Framework: ${generateConfig.sdk}`); @@ -338,6 +340,11 @@ export function useCreateFlow(cwd: string): CreateFlowState { () => configIO.readProjectSpec() ); } + // Auto-create config bundle when opted in + if (addAgentConfig.withConfigBundle) { + logger.logSubStep('Creating config bundle...'); + await createConfigBundleForAgent(addAgentConfig.name, configBaseDir); + } } else if (addAgentConfig.agentType === 'import') { // Import path: delegate to executeImportAgent logger.logSubStep(`Importing from Bedrock Agent: ${addAgentConfig.bedrockAgentId}`); diff --git a/src/cli/tui/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index 6e99e0cfc..319f970ec 100644 --- a/src/cli/tui/screens/deploy/DeployScreen.tsx +++ b/src/cli/tui/screens/deploy/DeployScreen.tsx @@ -76,6 +76,8 @@ export function DeployScreen({ diffSummaries, numStacksWithChanges, deployNotes, + postDeployWarnings, + postDeployHasError, isDiffLoading, requestDiff, hasError, @@ -342,7 +344,13 @@ export function DeployScreen({ {/* Show deploy status when deploying or complete */} {showDeployStatus && ( - + )} @@ -375,6 +383,20 @@ export function DeployScreen({ )} + {allSuccess && postDeployWarnings.length > 0 && ( + + + Post-deploy warnings: + + {postDeployWarnings.map((w, i) => ( + + {' '} + {w} + + ))} + + )} + {allSuccess && deployNotes.length > 0 && ( {deployNotes.map((note, i) => ( diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 786c70764..b27b3e528 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -15,6 +15,13 @@ import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from import { ExecLogger } from '../../../logging'; import { performStackTeardown, setupTransactionSearch } from '../../../operations/deploy'; import { getGatewayTargetStatuses } from '../../../operations/deploy/gateway-status'; +import { deleteOrphanedABTests, setupABTests } from '../../../operations/deploy/post-deploy-ab-tests'; +import { + resolveConfigBundleComponentKeys, + setupConfigBundles, +} from '../../../operations/deploy/post-deploy-config-bundles'; +import { setupHttpGateways } from '../../../operations/deploy/post-deploy-http-gateways'; +import { enableOnlineEvalConfigs } from '../../../operations/deploy/post-deploy-online-evals'; import { type StackDiffSummary, type Step, @@ -83,6 +90,10 @@ interface DeployFlowState { numStacksWithChanges?: number; /** Notes to display after successful deploy (e.g., transaction search info) */ deployNotes: string[]; + /** Warnings from post-deploy steps (config bundles, AB tests) */ + postDeployWarnings: string[]; + /** True if any post-deploy sub-resource operation had errors */ + postDeployHasError: boolean; /** Whether an on-demand diff is currently running */ isDiffLoading: boolean; /** Request an on-demand diff (lazy: runs once, caches result) */ @@ -133,6 +144,8 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const [numStacksWithChanges, setNumStacksWithChanges] = useState(); const [isDiffLoading, setIsDiffLoading] = useState(false); const [deployNotes, setDeployNotes] = useState([]); + const [postDeployWarnings, setPostDeployWarnings] = useState([]); + const [postDeployHasError, setPostDeployHasError] = useState(false); const isDiffRunningRef = useRef(false); const [deployOutput, setDeployOutput] = useState(null); const [deployMessages, setDeployMessages] = useState([]); @@ -274,8 +287,14 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const evaluators = parseEvaluatorOutputs(outputs, evaluatorNames); // Parse online eval config outputs - const onlineEvalNames = (ctx.projectSpec.onlineEvalConfigs ?? []).map((c: { name: string }) => c.name); - const onlineEvalConfigs = parseOnlineEvalOutputs(outputs, onlineEvalNames); + const onlineEvalSpecs = (ctx.projectSpec.onlineEvalConfigs ?? []).map( + (c: { name: string; agent?: string; endpoint?: string }) => ({ + name: c.name, + agent: c.agent, + endpoint: c.endpoint, + }) + ); + const onlineEvalConfigs = parseOnlineEvalOutputs(outputs, onlineEvalSpecs); // Parse policy engine outputs const policyEngineSpecs = ctx.projectSpec.policyEngines ?? []; @@ -292,7 +311,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState setStackOutputs(outputs); const existingState = await configIO.readDeployedState().catch(() => undefined); - const deployedState = buildDeployedState({ + let deployedState = buildDeployedState({ targetName: target.name, stackName: currentStackName, agents, @@ -308,6 +327,210 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState }); await configIO.writeDeployedState(deployedState); + // Post-deploy: Enable online eval configs that have enableOnCreate (CFN deploys them as DISABLED). + // Only enable configs that are newly deployed — skip configs that already existed before this + // deploy run, so we don't re-enable configs a customer intentionally disabled. + const onlineEvalFullSpecs = ctx.projectSpec.onlineEvalConfigs ?? []; + const deployedOnlineEvalConfigs = deployedState.targets?.[target.name]?.resources?.onlineEvalConfigs ?? {}; + const previouslyDeployedOnlineEvals = existingState?.targets?.[target.name]?.resources?.onlineEvalConfigs ?? {}; + const newOnlineEvalFullSpecs = onlineEvalFullSpecs.filter(c => !previouslyDeployedOnlineEvals[c.name]); + if (newOnlineEvalFullSpecs.length > 0 && Object.keys(deployedOnlineEvalConfigs).length > 0) { + try { + const enableResult = await enableOnlineEvalConfigs({ + region: target.region, + onlineEvalConfigs: newOnlineEvalFullSpecs, + deployedOnlineEvalConfigs, + }); + + if (enableResult.hasErrors) { + const errors = enableResult.results.filter(r => r.status === 'error'); + for (const err of errors) { + logger.log(`Online eval enable "${err.configName}" error: ${err.error}`, 'warn'); + } + setPostDeployHasError(true); + setPostDeployWarnings(prev => [ + ...prev, + ...errors.map(err => `Online eval "${err.configName}": ${err.error}`), + ]); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.log(`Online eval enable failed: ${message}`, 'warn'); + setPostDeployHasError(true); + setPostDeployWarnings(prev => [...prev, `Online eval enable failed: ${message}`]); + } + } + + // Post-deploy: Create/update configuration bundles + const configBundleSpecs = ctx.projectSpec.configBundles ?? []; + if (configBundleSpecs.length > 0) { + try { + // Resolve component key placeholders (e.g., {{runtime:name}} → real ARN) + const resolvedProjectSpec = resolveConfigBundleComponentKeys(ctx.projectSpec, deployedState, target.name); + const existingConfigBundles = deployedState.targets?.[target.name]?.resources?.configBundles; + const configBundleResult = await setupConfigBundles({ + region: target.region, + projectSpec: resolvedProjectSpec, + existingBundles: existingConfigBundles, + }); + + // Merge config bundle state into deployed state + if (Object.keys(configBundleResult.configBundles).length > 0) { + const updatedState = await configIO.readDeployedState().catch(() => deployedState); + const targetResources = updatedState.targets[target.name]?.resources; + if (targetResources) { + targetResources.configBundles = configBundleResult.configBundles; + await configIO.writeDeployedState(updatedState); + } + } + + if (configBundleResult.hasErrors) { + const errors = configBundleResult.results.filter(r => r.status === 'error'); + for (const err of errors) { + logger.log(`Config bundle "${err.bundleName}" setup error: ${err.error}`, 'warn'); + } + setPostDeployHasError(true); + setPostDeployWarnings(prev => [ + ...prev, + ...errors.map(err => `Config bundle "${err.bundleName}": ${err.error}`), + ]); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.log(`Config bundle setup failed: ${message}`, 'warn'); + setPostDeployHasError(true); + setPostDeployWarnings(prev => [...prev, `Config bundle setup failed: ${message}`]); + } + } + + // Pre-gateway: Delete orphaned AB tests so their gateway rules are cleaned up + // before we attempt to delete orphaned HTTP gateways. + const existingABTests = deployedState.targets?.[target.name]?.resources?.abTests; + if (existingABTests && Object.keys(existingABTests).length > 0) { + try { + const deleteResult = await deleteOrphanedABTests({ + region: target.region, + projectSpec: ctx.projectSpec, + existingABTests, + }); + + if (deleteResult.hasErrors) { + const errors = deleteResult.results.filter(r => r.status === 'error'); + for (const err of errors) { + logger.log(`AB test delete "${err.testName}" error: ${err.error}`, 'warn'); + } + setPostDeployHasError(true); + setPostDeployWarnings(prev => [...prev, ...errors.map(err => `AB test "${err.testName}": ${err.error}`)]); + } + + // Surface warnings (e.g., "AB test was stopped before deletion") + for (const r of deleteResult.results) { + if (r.warning) { + logger.log(r.warning, 'warn'); + setPostDeployWarnings(prev => [...prev, r.warning!]); + } + } + + // Update deployed state to remove deleted AB tests + if (deleteResult.results.some(r => r.status === 'deleted')) { + const updatedState = await configIO.readDeployedState().catch(() => deployedState); + const targetResources = updatedState.targets[target.name]?.resources; + if (targetResources?.abTests) { + for (const r of deleteResult.results) { + if (r.status === 'deleted') delete targetResources.abTests[r.testName]; + } + await configIO.writeDeployedState(updatedState); + deployedState = updatedState; + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.log(`AB test orphan cleanup failed: ${message}`, 'warn'); + setPostDeployHasError(true); + setPostDeployWarnings(prev => [...prev, `AB test orphan cleanup failed: ${message}`]); + } + } + + // Post-deploy: Create/update HTTP gateways + const httpGatewaySpecs = ctx.projectSpec.httpGateways ?? []; + const existingHttpGateways = deployedState.targets?.[target.name]?.resources?.httpGateways; + if (httpGatewaySpecs.length > 0 || Object.keys(existingHttpGateways ?? {}).length > 0) { + try { + const deployedResources = deployedState.targets?.[target.name]?.resources; + const httpGatewayResult = await setupHttpGateways({ + region: target.region, + projectName: ctx.projectSpec.name, + projectSpec: ctx.projectSpec, + existingHttpGateways, + deployedResources, + }); + + // Always merge HTTP gateway state (even if empty, to clear deleted gateways) + const updatedState = await configIO.readDeployedState().catch(() => deployedState); + const targetResources = updatedState.targets[target.name]?.resources; + if (targetResources) { + targetResources.httpGateways = httpGatewayResult.httpGateways; + await configIO.writeDeployedState(updatedState); + deployedState = updatedState; + } + + if (httpGatewayResult.hasErrors) { + const errors = httpGatewayResult.results.filter(r => r.status === 'error'); + for (const err of errors) { + logger.log(`HTTP gateway "${err.gatewayName}" setup error: ${err.error}`, 'warn'); + } + setPostDeployHasError(true); + setPostDeployWarnings(prev => [ + ...prev, + ...errors.map(err => `HTTP gateway "${err.gatewayName}": ${err.error}`), + ]); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.log(`HTTP gateway setup failed: ${message}`, 'warn'); + setPostDeployHasError(true); + setPostDeployWarnings(prev => [...prev, `HTTP gateway setup failed: ${message}`]); + } + } + + // Post-deploy: Create/update AB tests + const abTestSpecs = ctx.projectSpec.abTests ?? []; + if (abTestSpecs.length > 0) { + try { + const existingABTests = deployedState.targets?.[target.name]?.resources?.abTests; + const deployedResources = deployedState.targets?.[target.name]?.resources; + const abTestResult = await setupABTests({ + region: target.region, + projectSpec: ctx.projectSpec, + existingABTests, + deployedResources, + }); + + if (Object.keys(abTestResult.abTests).length > 0) { + const updatedState = await configIO.readDeployedState().catch(() => deployedState); + const targetResources = updatedState.targets[target.name]?.resources; + if (targetResources) { + targetResources.abTests = abTestResult.abTests; + await configIO.writeDeployedState(updatedState); + } + } + + if (abTestResult.hasErrors) { + const errors = abTestResult.results.filter(r => r.status === 'error'); + for (const err of errors) { + logger.log(`AB test "${err.testName}" setup error: ${err.error}`, 'warn'); + } + setPostDeployHasError(true); + setPostDeployWarnings(prev => [...prev, ...errors.map(err => `AB test "${err.testName}": ${err.error}`)]); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.log(`AB test setup failed: ${message}`, 'warn'); + setPostDeployHasError(true); + setPostDeployWarnings(prev => [...prev, `AB test setup failed: ${message}`]); + } + } + // Query gateway target sync statuses (non-blocking) const allStatuses: { name: string; status: string }[] = []; for (const [, gateway] of Object.entries(gateways)) { @@ -650,6 +873,8 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState diffSummaries, numStacksWithChanges, deployNotes, + postDeployWarnings, + postDeployHasError, isDiffLoading, requestDiff, stackOutputs, diff --git a/src/cli/tui/screens/eval/EvalHubScreen.tsx b/src/cli/tui/screens/eval/EvalHubScreen.tsx index 67056413d..27cb2e66f 100644 --- a/src/cli/tui/screens/eval/EvalHubScreen.tsx +++ b/src/cli/tui/screens/eval/EvalHubScreen.tsx @@ -4,7 +4,7 @@ import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import React, { useMemo } from 'react'; -type EvalHubView = 'run-eval' | 'runs' | 'online-dashboard'; +type EvalHubView = 'run-eval' | 'runs' | 'run-batch-eval' | 'batch-eval-history' | 'online-dashboard'; interface EvalHubScreenProps { onSelect: (view: EvalHubView) => void; @@ -19,7 +19,17 @@ export function EvalHubScreen({ onSelect, onExit }: EvalHubScreenProps) { title: 'Run On-demand Evaluation', description: 'Evaluate agent traces with selected evaluators', }, - { id: 'runs', title: 'Eval Runs', description: 'View past eval run results and scores' }, + { id: 'runs', title: 'Eval Runs', description: 'View past on-demand eval results and scores' }, + { + id: 'run-batch-eval', + title: 'Run Batch Evaluation', + description: 'Run a batch evaluation against agent sessions via CloudWatch', + }, + { + id: 'batch-eval-history', + title: 'Batch Eval History', + description: 'View past batch evaluation results (local)', + }, { id: 'online-dashboard', title: 'Online Eval Dashboard', diff --git a/src/cli/tui/screens/generate/GenerateWizardUI.tsx b/src/cli/tui/screens/generate/GenerateWizardUI.tsx index 4b61e4689..9c6c79599 100644 --- a/src/cli/tui/screens/generate/GenerateWizardUI.tsx +++ b/src/cli/tui/screens/generate/GenerateWizardUI.tsx @@ -577,6 +577,12 @@ function ConfirmView({ config, credentialProjectName }: { config: GenerateConfig {config.sessionStorageMountPath} )} + {config.withConfigBundle && ( + + Config Bundle: + Yes (auto-created on deploy) + + )} ); diff --git a/src/cli/tui/screens/generate/types.ts b/src/cli/tui/screens/generate/types.ts index 697b318bd..1b764ea80 100644 --- a/src/cli/tui/screens/generate/types.ts +++ b/src/cli/tui/screens/generate/types.ts @@ -64,6 +64,8 @@ export interface GenerateConfig { maxLifetime?: number; /** Mount path for session filesystem storage (e.g. /mnt/session-storage) */ sessionStorageMountPath?: string; + /** When true, create a config bundle wired into the agent template */ + withConfigBundle?: boolean; } /** Base steps - apiKey, memory, subnets, securityGroups are conditionally added based on selections */ @@ -158,7 +160,14 @@ export const NETWORK_MODE_OPTIONS = [ { id: 'VPC', title: 'VPC', description: 'Attach to your VPC' }, ] as const; -export type AdvancedSettingId = 'dockerfile' | 'network' | 'headers' | 'auth' | 'lifecycle' | 'filesystem'; +export type AdvancedSettingId = + | 'dockerfile' + | 'network' + | 'headers' + | 'auth' + | 'lifecycle' + | 'filesystem' + | 'configBundle'; export const ADVANCED_SETTING_OPTIONS = [ { id: 'dockerfile', title: 'Custom Dockerfile', description: 'Specify a custom Dockerfile path' }, @@ -167,6 +176,11 @@ export const ADVANCED_SETTING_OPTIONS = [ { id: 'auth', title: 'Custom auth (JWT)', description: 'OIDC-based token validation for inbound requests' }, { id: 'lifecycle', title: 'Lifecycle timeouts', description: 'Idle timeout & max instance lifetime' }, { id: 'filesystem', title: 'Session filesystem storage', description: 'Persist files across session stop/resume' }, + { + id: 'configBundle', + title: 'Config bundle [preview]', + description: 'Manage system prompt and tool config without redeploying', + }, ] as const; /** Dockerfile filename regex — must match the Zod schema in agent-env.ts */ diff --git a/src/cli/tui/screens/generate/useGenerateWizard.ts b/src/cli/tui/screens/generate/useGenerateWizard.ts index 16e016ad6..411cfb151 100644 --- a/src/cli/tui/screens/generate/useGenerateWizard.ts +++ b/src/cli/tui/screens/generate/useGenerateWizard.ts @@ -89,6 +89,7 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { if (advancedSettings.has('filesystem')) { subSteps.push('sessionStorageMountPath'); } + // Config bundle — no sub-steps needed, uses smart defaults filtered = [...filtered.slice(0, afterAdvanced), ...subSteps, ...filtered.slice(afterAdvanced)]; } // Add jwtConfig step after authorizerType when CUSTOM_JWT is selected @@ -234,9 +235,12 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { idleRuntimeSessionTimeout: undefined, maxLifetime: undefined, sessionStorageMountPath: undefined, + withConfigBundle: undefined, })); setStep('confirm'); } else { + // Config bundle has no sub-steps — set flag immediately + setConfig(c => ({ ...c, withConfigBundle: selected.has('configBundle') || undefined })); // Navigate to first advanced sub-step — determined by the steps memo on next render. // Use setTimeout so the steps memo recalculates with the new advancedSettings first. setTimeout(() => { diff --git a/src/cli/tui/screens/home/HelpScreen.tsx b/src/cli/tui/screens/home/HelpScreen.tsx index 485c263c7..71b8d56e8 100644 --- a/src/cli/tui/screens/home/HelpScreen.tsx +++ b/src/cli/tui/screens/home/HelpScreen.tsx @@ -218,11 +218,11 @@ export function HelpScreen(props: { const interactiveItems = useMemo((): DisplayItem[] => { return commands.filter(cmd => !cmd.cliOnly).flatMap(filterCommand); - }, [commands, query]); + }, [commands, filterCommand]); const cliOnlyItems = useMemo((): DisplayItem[] => { return commands.filter(cmd => cmd.cliOnly).flatMap(filterCommand); - }, [commands, query]); + }, [commands, filterCommand]); const visibleCliOnlyItems = query ? cliOnlyItems : showCliOnly ? cliOnlyItems : []; diff --git a/src/cli/tui/screens/identity/useCreateIdentity.ts b/src/cli/tui/screens/identity/useCreateIdentity.ts index 42aace21b..e214d1e67 100644 --- a/src/cli/tui/screens/identity/useCreateIdentity.ts +++ b/src/cli/tui/screens/identity/useCreateIdentity.ts @@ -2,6 +2,7 @@ import { ConfigIO } from '../../../../lib'; import type { Credential } from '../../../../schema'; import type { AddCredentialOptions } from '../../../primitives/CredentialPrimitive'; import { credentialPrimitive } from '../../../primitives/registry'; +import { withAddTelemetry } from '../../../telemetry/cli-command-run.js'; import { useCallback, useEffect, useState } from 'react'; interface CreateStatus { @@ -16,7 +17,13 @@ export function useCreateIdentity() { const create = useCallback(async (config: AddCredentialOptions) => { setStatus({ state: 'loading' }); try { - const result = await credentialPrimitive.add(config); + const result = await withAddTelemetry( + 'add.credential', + { + credential_type: config.authorizerType === 'OAuthCredentialProvider' ? 'oauth' : 'api-key', + }, + () => credentialPrimitive.add(config) + ); if (!result.success) { throw new Error(result.error ?? 'Failed to create credential'); } diff --git a/src/cli/tui/screens/import/ArnInputScreen.tsx b/src/cli/tui/screens/import/ArnInputScreen.tsx index e56957508..9381ca7b8 100644 --- a/src/cli/tui/screens/import/ArnInputScreen.tsx +++ b/src/cli/tui/screens/import/ArnInputScreen.tsx @@ -24,6 +24,7 @@ const RESOURCE_TYPE_LABELS: Record = { memory: 'Import Memory', evaluator: 'Import Evaluator', 'online-eval': 'Import Online Eval Config', + gateway: 'Import Gateway', }; export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScreenProps) { @@ -40,6 +41,7 @@ export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScree onSubmit={onSubmit} onCancel={onExit} customValidation={validateArn} + expandable /> diff --git a/src/cli/tui/screens/import/ImportProgressScreen.tsx b/src/cli/tui/screens/import/ImportProgressScreen.tsx index bd7096d5f..3771ffbab 100644 --- a/src/cli/tui/screens/import/ImportProgressScreen.tsx +++ b/src/cli/tui/screens/import/ImportProgressScreen.tsx @@ -49,7 +49,9 @@ export function ImportProgressScreen({ ? (await import('../../../commands/import/import-memory')).handleImportMemory : importType === 'evaluator' ? (await import('../../../commands/import/import-evaluator')).handleImportEvaluator - : (await import('../../../commands/import/import-online-eval')).handleImportOnlineEval; + : importType === 'gateway' + ? (await import('../../../commands/import/import-gateway')).handleImportGateway + : (await import('../../../commands/import/import-online-eval')).handleImportOnlineEval; const result = await handler({ arn, code, onProgress }); if (result.success) { diff --git a/src/cli/tui/screens/import/ImportSelectScreen.tsx b/src/cli/tui/screens/import/ImportSelectScreen.tsx index 092e255b5..9058ebb1e 100644 --- a/src/cli/tui/screens/import/ImportSelectScreen.tsx +++ b/src/cli/tui/screens/import/ImportSelectScreen.tsx @@ -1,7 +1,7 @@ import type { SelectableItem } from '../../components/SelectList'; import { SelectScreen } from '../../components/SelectScreen'; -export type ImportType = 'runtime' | 'memory' | 'evaluator' | 'online-eval' | 'starter-toolkit'; +export type ImportType = 'runtime' | 'memory' | 'evaluator' | 'online-eval' | 'gateway' | 'starter-toolkit'; interface ImportSelectItem extends SelectableItem { id: ImportType; @@ -28,6 +28,11 @@ const IMPORT_OPTIONS: ImportSelectItem[] = [ title: 'Online Eval Config', description: 'Import an existing AgentCore Online Evaluation Config from your AWS account', }, + { + id: 'gateway', + title: 'Gateway', + description: 'Import an existing AgentCore Gateway (with targets) from your AWS account', + }, { id: 'starter-toolkit', title: 'From Starter Toolkit', diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index 2cb49b071..0202d6dab 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -42,6 +42,7 @@ export interface InvokeConfig { networkMode?: NetworkMode; protocol?: ProtocolMode; authorizerType?: RuntimeAuthorizerType; + baggage?: string; }[]; target: AwsDeploymentTarget; targetName: string; @@ -136,9 +137,24 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState } const runtimes: InvokeConfig['runtimes'] = []; + const deployedBundles = targetState?.resources?.configBundles ?? {}; for (const agent of project.runtimes) { const state = targetState?.resources?.runtimes?.[agent.name]; if (!state) continue; + + // Build config bundle baggage if a bundle is associated with this agent + let baggage: string | undefined; + const bundleSpec = project.configBundles?.find(b => { + const keys = Object.keys(b.components ?? {}); + return keys.some(k => k === `{{runtime:${agent.name}}}`); + }); + if (bundleSpec) { + const bundleState = deployedBundles[bundleSpec.name]; + if (bundleState?.bundleArn && bundleState?.versionId) { + baggage = `aws.agentcore.configbundle_arn=${encodeURIComponent(bundleState.bundleArn)},aws.agentcore.configbundle_version=${encodeURIComponent(bundleState.versionId)}`; + } + } + runtimes.push({ name: agent.name, state, @@ -146,6 +162,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState networkMode: agent.networkMode, protocol: agent.protocol, authorizerType: agent.authorizerType, + baggage, }); } @@ -469,6 +486,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState logger, headers, bearerToken: bearerToken || undefined, + baggage: agent.baggage, }); if (result.sessionId) { diff --git a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx index 92c56d90e..243eba4e7 100644 --- a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx +++ b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx @@ -3,16 +3,17 @@ import { validateAwsCredentials } from '../../../aws/account'; import { listEvaluators } from '../../../aws/agentcore-control'; import { detectRegion } from '../../../aws/region'; import { getErrorMessage } from '../../../errors'; -import { ErrorPrompt } from '../../components'; +import { ErrorPrompt, GradientText } from '../../components'; import { useCreateOnlineEval, useExistingOnlineEvalNames } from '../../hooks/useCreateOnlineEval'; import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import type { RuntimeInfoForEval } from './AddOnlineEvalScreen'; import { AddOnlineEvalScreen } from './AddOnlineEvalScreen'; import type { AddOnlineEvalConfig, EvaluatorItem } from './types'; import React, { useCallback, useEffect, useState } from 'react'; type FlowState = | { name: 'loading' } - | { name: 'create-wizard'; evaluators: EvaluatorItem[]; agentNames: string[] } + | { name: 'create-wizard'; evaluators: EvaluatorItem[]; agentNames: string[]; runtimes: RuntimeInfoForEval[] } | { name: 'create-success'; configName: string } | { name: 'creds-error'; message: string } | { name: 'error'; message: string }; @@ -55,7 +56,8 @@ export function AddOnlineEvalFlow({ isInteractive = true, onExit, onBack, onDev, description: e.description, })); - const agentNames = projectSpec.runtimes.map(a => a.name); + const runtimesList = projectSpec.runtimes ?? []; + const agentNames = runtimesList.map(a => a.name); if (agentNames.length === 0) { setFlow({ @@ -65,7 +67,16 @@ export function AddOnlineEvalFlow({ isInteractive = true, onExit, onBack, onDev, return; } - setFlow({ name: 'create-wizard', evaluators: items, agentNames }); + // Build runtime info with endpoints for the endpoint picker + const runtimesInfo: RuntimeInfoForEval[] = runtimesList.map(r => ({ + name: r.name, + endpoints: Object.entries(r.endpoints ?? {}).map(([epName, ep]) => ({ + name: epName, + version: ep.version, + })), + })); + + setFlow({ name: 'create-wizard', evaluators: items, agentNames, runtimes: runtimesInfo }); } catch (err) { if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); } @@ -96,7 +107,7 @@ export function AddOnlineEvalFlow({ isInteractive = true, onExit, onBack, onDev, ); if (flow.name === 'loading') { - return null; + return ; } if (flow.name === 'creds-error') { @@ -109,6 +120,7 @@ export function AddOnlineEvalFlow({ isInteractive = true, onExit, onBack, onDev, existingConfigNames={existingConfigNames} evaluatorItems={flow.evaluators} agentNames={flow.agentNames} + runtimes={flow.runtimes} onComplete={handleCreateComplete} onExit={onBack} /> diff --git a/src/cli/tui/screens/online-eval/AddOnlineEvalScreen.tsx b/src/cli/tui/screens/online-eval/AddOnlineEvalScreen.tsx index fc863a2d1..fd5fafcf6 100644 --- a/src/cli/tui/screens/online-eval/AddOnlineEvalScreen.tsx +++ b/src/cli/tui/screens/online-eval/AddOnlineEvalScreen.tsx @@ -12,11 +12,17 @@ import { import { HELP_TEXT } from '../../constants'; import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; -import type { AddOnlineEvalConfig, EvaluatorItem } from './types'; +import type { AddOnlineEvalConfig, EvaluatorItem, RuntimeEndpointEntry } from './types'; import { DEFAULT_SAMPLING_RATE, ONLINE_EVAL_STEP_LABELS } from './types'; import { useAddOnlineEvalWizard } from './useAddOnlineEvalWizard'; import { Box, Text } from 'ink'; -import React, { useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; + +/** Runtime info with endpoints, passed from the parent flow. */ +export interface RuntimeInfoForEval { + name: string; + endpoints: RuntimeEndpointEntry[]; +} interface AddOnlineEvalScreenProps { onComplete: (config: AddOnlineEvalConfig) => void; @@ -24,6 +30,8 @@ interface AddOnlineEvalScreenProps { existingConfigNames: string[]; evaluatorItems: EvaluatorItem[]; agentNames: string[]; + /** Runtime info including endpoints for the endpoint picker step. */ + runtimes?: RuntimeInfoForEval[]; } export function AddOnlineEvalScreen({ @@ -32,6 +40,7 @@ export function AddOnlineEvalScreen({ existingConfigNames, evaluatorItems: rawEvaluatorItems, agentNames, + runtimes = [], }: AddOnlineEvalScreenProps) { const wizard = useAddOnlineEvalWizard(agentNames.length); @@ -43,6 +52,36 @@ export function AddOnlineEvalScreen({ return wizard.config; }, [wizard.config, agentNames]); + // Determine endpoints for the currently selected agent + const agentEndpoints = useMemo(() => { + const agentName = effectiveConfig.agent; + if (!agentName) return []; + const rt = runtimes.find(r => r.name === agentName); + return rt?.endpoints ?? []; + }, [effectiveConfig.agent, runtimes]); + + // Skip endpoint step when the selected agent has no endpoints + const shouldSkipStep = useCallback( + (s: string) => { + if (s === 'endpoint' && agentEndpoints.length === 0) return true; + return false; + }, + [agentEndpoints.length] + ); + + useEffect(() => { + wizard.setSkipCheck(shouldSkipStep); + }, [shouldSkipStep]); // wizard.setSkipCheck is stable (useCallback with no deps) + + // Build endpoint picker items: DEFAULT (plain) + each endpoint + const endpointItems: SelectableItem[] = useMemo(() => { + const items: SelectableItem[] = [{ id: 'DEFAULT', title: 'DEFAULT' }]; + for (const ep of agentEndpoints) { + items.push({ id: ep.name, title: ep.name, description: `v${ep.version}` }); + } + return items; + }, [agentEndpoints]); + const evaluatorItems: SelectableItem[] = useMemo(() => { return rawEvaluatorItems.map(e => ({ id: e.arn, @@ -57,6 +96,7 @@ export function AddOnlineEvalScreen({ const isNameStep = wizard.step === 'name'; const isAgentStep = wizard.step === 'agent'; + const isEndpointStep = wizard.step === 'endpoint'; const isEvaluatorsStep = wizard.step === 'evaluators'; const isSamplingRateStep = wizard.step === 'samplingRate'; const isEnableOnCreateStep = wizard.step === 'enableOnCreate'; @@ -77,6 +117,16 @@ export function AddOnlineEvalScreen({ isActive: isAgentStep, }); + const endpointNav = useListNavigation({ + items: endpointItems, + onSelect: item => { + // DEFAULT means no endpoint filter — store undefined + wizard.setEndpoint(item.id === 'DEFAULT' ? undefined : item.id); + }, + onExit: () => wizard.goBack(), + isActive: isEndpointStep, + }); + const evaluatorsNav = useMultiSelectNavigation({ items: evaluatorItems, getId: item => item.id, @@ -102,7 +152,7 @@ export function AddOnlineEvalScreen({ const helpText = isEvaluatorsStep ? 'Space toggle · Enter confirm · Esc back' - : isAgentStep || isEnableOnCreateStep + : isAgentStep || isEndpointStep || isEnableOnCreateStep ? HELP_TEXT.NAVIGATE_SELECT : isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL @@ -136,6 +186,14 @@ export function AddOnlineEvalScreen({ /> )} + {isEndpointStep && ( + + )} + {isEvaluatorsStep && ( = { name: 'Name', agent: 'Agent', + endpoint: 'Endpoint', evaluators: 'Evaluators', samplingRate: 'Rate', enableOnCreate: 'Enable', diff --git a/src/cli/tui/screens/online-eval/useAddOnlineEvalWizard.ts b/src/cli/tui/screens/online-eval/useAddOnlineEvalWizard.ts index 0032469f2..239a95edc 100644 --- a/src/cli/tui/screens/online-eval/useAddOnlineEvalWizard.ts +++ b/src/cli/tui/screens/online-eval/useAddOnlineEvalWizard.ts @@ -1,40 +1,58 @@ import type { AddOnlineEvalConfig, AddOnlineEvalStep } from './types'; import { DEFAULT_SAMPLING_RATE } from './types'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; function getAllSteps(agentCount: number): AddOnlineEvalStep[] { if (agentCount <= 1) { - return ['name', 'evaluators', 'samplingRate', 'enableOnCreate', 'confirm']; + // endpoint step is included but will be skipped dynamically when no endpoints exist + return ['name', 'endpoint', 'evaluators', 'samplingRate', 'enableOnCreate', 'confirm']; } - return ['name', 'agent', 'evaluators', 'samplingRate', 'enableOnCreate', 'confirm']; + return ['name', 'agent', 'endpoint', 'evaluators', 'samplingRate', 'enableOnCreate', 'confirm']; } function getDefaultConfig(): AddOnlineEvalConfig { return { name: '', agent: '', + endpoint: undefined, evaluators: [], samplingRate: DEFAULT_SAMPLING_RATE, enableOnCreate: true, }; } +type StepSkipCheck = (step: AddOnlineEvalStep) => boolean; + export function useAddOnlineEvalWizard(agentCount: number) { const allSteps = getAllSteps(agentCount); const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState(allSteps[0]!); + const skipCheckRef = useRef(() => false); const currentIndex = allSteps.indexOf(step); + const setSkipCheck = useCallback((check: StepSkipCheck) => { + skipCheckRef.current = check; + }, []); + const goBack = useCallback(() => { - const prevStep = allSteps[currentIndex - 1]; - if (prevStep) setStep(prevStep); + for (let i = currentIndex - 1; i >= 0; i--) { + if (!skipCheckRef.current(allSteps[i]!)) { + setStep(allSteps[i]!); + return; + } + } }, [allSteps, currentIndex, setStep]); const nextStep = useCallback( (currentStep: AddOnlineEvalStep): AddOnlineEvalStep | undefined => { const idx = allSteps.indexOf(currentStep); - return allSteps[idx + 1]; + for (let i = idx + 1; i < allSteps.length; i++) { + if (!skipCheckRef.current(allSteps[i]!)) { + return allSteps[i]!; + } + } + return undefined; }, [allSteps] ); @@ -50,13 +68,22 @@ export function useAddOnlineEvalWizard(agentCount: number) { const setAgent = useCallback( (agent: string) => { - setConfig(c => ({ ...c, agent })); + setConfig(c => ({ ...c, agent, endpoint: undefined })); const next = nextStep('agent'); if (next) setStep(next); }, [nextStep, setConfig, setStep] ); + const setEndpoint = useCallback( + (endpoint: string | undefined) => { + setConfig(c => ({ ...c, endpoint })); + const next = nextStep('endpoint'); + if (next) setStep(next); + }, + [nextStep, setConfig, setStep] + ); + const setEvaluators = useCallback( (evaluators: string[]) => { setConfig(c => ({ ...c, evaluators })); @@ -95,8 +122,10 @@ export function useAddOnlineEvalWizard(agentCount: number) { steps: allSteps, currentIndex, goBack, + setSkipCheck, setName, setAgent, + setEndpoint, setEvaluators, setSamplingRate, setEnableOnCreate, diff --git a/src/cli/tui/screens/policy/AddPolicyFlow.tsx b/src/cli/tui/screens/policy/AddPolicyFlow.tsx index 720984563..9b3542cb8 100644 --- a/src/cli/tui/screens/policy/AddPolicyFlow.tsx +++ b/src/cli/tui/screens/policy/AddPolicyFlow.tsx @@ -1,4 +1,6 @@ import { policyEnginePrimitive, policyPrimitive } from '../../../primitives/registry'; +import { withAddTelemetry } from '../../../telemetry/cli-command-run.js'; +import { AttachMode, ValidationMode, standardize } from '../../../telemetry/schemas/common-shapes.js'; import { ErrorPrompt, Panel, @@ -128,7 +130,14 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD }, []); const commitEngine = useCallback(async (engineName: string, gateways?: string[], mode?: 'LOG_ONLY' | 'ENFORCE') => { - const result = await policyEnginePrimitive.add({ name: engineName }); + const result = await withAddTelemetry( + 'add.policy-engine', + { + attach_gateway_count: gateways?.length ?? 0, + attach_mode: standardize(AttachMode, mode ?? 'log_only'), + }, + () => policyEnginePrimitive.add({ name: engineName }) + ); if (!result.success) { setFlow({ name: 'error', message: result.error }); return; @@ -155,13 +164,21 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD ); const handlePolicyComplete = useCallback(async (config: AddPolicyConfig) => { - const result = await policyPrimitive.add({ - name: config.name, - engine: config.engine, - statement: config.statement, - source: config.sourceFile || undefined, - validationMode: config.validationMode, - }); + const result = await withAddTelemetry( + 'add.policy', + { + source_type: config.sourceFile ? 'file' : config.sourceMethod === 'generate' ? 'generate' : 'statement', + validation_mode: standardize(ValidationMode, config.validationMode ?? 'FAIL_ON_ANY_FINDINGS'), + }, + () => + policyPrimitive.add({ + name: config.name, + engine: config.engine, + statement: config.statement, + source: config.sourceFile || undefined, + validationMode: config.validationMode, + }) + ); if (result.success) { setPolicyNames(prev => [...prev, config.name]); diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx new file mode 100644 index 000000000..b28344df3 --- /dev/null +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -0,0 +1,552 @@ +import { ConfigIO } from '../../../../lib'; +import type { DeployedState } from '../../../../schema'; +import { validateAwsCredentials } from '../../../aws/account'; +import { listEvaluators } from '../../../aws/agentcore-control'; +import { detectRegion } from '../../../aws/region'; +import { getErrorMessage } from '../../../errors'; +import { applyRecommendationToBundle, runRecommendationCommand } from '../../../operations/recommendation'; +import type { RunRecommendationCommandResult } from '../../../operations/recommendation'; +import { saveRecommendationRun } from '../../../operations/recommendation/recommendation-storage'; +import { ErrorPrompt, GradientText, Panel, Screen, StepProgress } from '../../components'; +import type { Step } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { RecommendationScreen } from './RecommendationScreen'; +import type { + AgentItem, + ConfigBundleField, + ConfigBundleItem, + EvaluatorItem, + RecommendationWizardConfig, +} from './types'; +import { Box, Text } from 'ink'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'loading' } + | { name: 'wizard'; agents: AgentItem[]; evaluators: EvaluatorItem[]; configBundles: ConfigBundleItem[] } + | { + name: 'running'; + config: RecommendationWizardConfig; + steps: Step[]; + elapsed: number; + recommendationId?: string; + region?: string; + } + | { name: 'results'; result: RunRecommendationCommandResult; config: RecommendationWizardConfig; filePath?: string } + | { name: 'creds-error'; message: string } + | { name: 'error'; message: string; logFilePath?: string }; + +interface RecommendationFlowProps { + onExit: () => void; +} + +export function RecommendationFlow({ onExit }: RecommendationFlowProps) { + const [flow, setFlow] = useState({ name: 'loading' }); + + // Load agents and evaluators + useEffect(() => { + if (flow.name !== 'loading') return; + let cancelled = false; + + void (async () => { + try { + await validateAwsCredentials(); + } catch (err) { + if (!cancelled) setFlow({ name: 'creds-error', message: getErrorMessage(err) }); + return; + } + + try { + const configIO = new ConfigIO(); + const [{ region }, deployedState] = await Promise.all([detectRegion(), configIO.readDeployedState()]); + + if (cancelled) return; + + const agents = buildAgentItems(deployedState); + if (agents.length === 0) { + setFlow({ + name: 'error', + message: 'No deployed agents found. Run `agentcore deploy` first.', + }); + return; + } + + const evalResult = await listEvaluators({ region }); + if (cancelled) return; + + const evaluators: EvaluatorItem[] = evalResult.evaluators.map(e => ({ + id: e.evaluatorArn || e.evaluatorName, + title: e.evaluatorName, + description: e.description ?? e.evaluatorType, + })); + + const projectSpec = await configIO.readProjectSpec(); + const configBundles = buildConfigBundleItems(deployedState, projectSpec.configBundles ?? []); + + setFlow({ name: 'wizard', agents, evaluators, configBundles }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [flow.name]); + + const handleRunComplete = useCallback((config: RecommendationWizardConfig) => { + const willFetchSpans = config.traceSource === 'sessions'; + + const initialSteps: Step[] = [ + ...(willFetchSpans ? [{ label: 'Fetching session spans from CloudWatch...', status: 'pending' as const }] : []), + { label: 'Starting recommendation...', status: 'running' }, + { label: 'Polling for results', status: 'pending' }, + { label: 'Saving results', status: 'pending' }, + ]; + + // If auto-fetching, the first step is active + if (willFetchSpans) { + initialSteps[0] = { ...initialSteps[0]!, status: 'running' }; + initialSteps[1] = { ...initialSteps[1]!, status: 'pending' }; + } + + setFlow({ name: 'running', config, steps: initialSteps, elapsed: 0 }); + }, []); + + // Execute the recommendation when entering 'running' state + useEffect(() => { + if (flow.name !== 'running') return; + let cancelled = false; + + const { config } = flow; + const startTime = Date.now(); + + const timer = setInterval(() => { + if (!cancelled) { + setFlow(prev => { + if (prev.name !== 'running') return prev; + return { ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) }; + }); + } + }, 1000); + + void (async () => { + try { + const result = await runRecommendationCommand({ + type: config.type, + agent: config.agent, + evaluators: config.evaluators, + inputSource: config.inputSource, + inlineContent: config.inputSource === 'inline' ? config.content : undefined, + promptFile: config.inputSource === 'file' ? config.content : undefined, + bundleName: config.inputSource === 'config-bundle' ? config.bundleName : undefined, + bundleVersion: config.inputSource === 'config-bundle' ? config.bundleVersion : undefined, + systemPromptJsonPath: + config.inputSource === 'config-bundle' && config.systemPromptJsonPath + ? config.systemPromptJsonPath + : undefined, + toolDescJsonPaths: + config.inputSource === 'config-bundle' && config.toolDescJsonPaths.length > 0 + ? config.toolDescJsonPaths + : undefined, + tools: config.tools + ? config.tools + .split(/,(?=[a-zA-Z0-9_\-.]+:)/) + .map(t => t.trim()) + .filter(Boolean) + : undefined, + traceSource: config.traceSource, + lookbackDays: config.days, + sessionIds: config.sessionIds.length > 0 ? config.sessionIds : undefined, + onProgress: (status, _message) => { + if (cancelled) return; + const hasFetchStep = config.traceSource === 'sessions'; + const offset = hasFetchStep ? 1 : 0; + + setFlow(prev => { + if (prev.name !== 'running') return prev; + const steps = [...prev.steps]; + if (status === 'fetching-spans') { + steps[0] = { ...steps[0]!, status: 'running' }; + } else if (status === 'starting') { + if (hasFetchStep) steps[0] = { ...steps[0]!, status: 'success' }; + steps[offset] = { ...steps[offset]!, status: 'running' }; + } else if (status === 'started' || status === 'polling') { + steps[offset] = { ...steps[offset]!, status: 'success' }; + steps[offset + 1] = { ...steps[offset + 1]!, status: 'running' }; + } + return { ...prev, steps }; + }); + }, + onStarted: info => { + setFlow(prev => { + if (prev.name !== 'running') return prev; + return { ...prev, recommendationId: info.recommendationId, region: info.region }; + }); + }, + }); + + clearInterval(timer); + if (cancelled) return; + + if (!result.success) { + setFlow(prev => { + if (prev.name !== 'running') return prev; + const steps = prev.steps.map(s => + s.status === 'running' ? { ...s, status: 'error' as const, error: result.error } : s + ); + return { ...prev, steps }; + }); + await new Promise(resolve => setTimeout(resolve, 2000)); + if (cancelled) return; + setFlow({ name: 'error', message: result.error ?? 'Recommendation failed', logFilePath: result.logFilePath }); + return; + } + + // Mark polling success, saving running + const hasFetchStep = config.traceSource === 'sessions'; + const offset = hasFetchStep ? 1 : 0; + + setFlow(prev => { + if (prev.name !== 'running') return prev; + const steps = [...prev.steps]; + steps[offset + 1] = { ...steps[offset + 1]!, status: 'success' }; + steps[offset + 2] = { ...steps[offset + 2]!, status: 'running' }; + return { ...prev, steps }; + }); + + // Save results locally + let filePath: string | undefined; + try { + if (result.recommendationId) { + filePath = saveRecommendationRun( + result.recommendationId, + result, + config.type, + config.agent, + config.evaluators + ); + } + } catch { + // Non-fatal + } + + setFlow({ name: 'results', result, config, filePath }); + } catch (err) { + clearInterval(timer); + if (!cancelled) { + const errorMsg = getErrorMessage(err); + setFlow(prev => { + if (prev.name !== 'running') return prev; + const steps = prev.steps.map(s => + s.status === 'running' ? { ...s, status: 'error' as const, error: errorMsg } : s + ); + return { ...prev, steps }; + }); + await new Promise(resolve => setTimeout(resolve, 2000)); + setFlow({ name: 'error', message: errorMsg }); + } + } + })(); + + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [flow.name]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Render states ───────────────────────────────────────────────────────── + + if (flow.name === 'loading') { + return ( + + + + ); + } + + if (flow.name === 'creds-error') { + return ; + } + + if (flow.name === 'wizard') { + return ( + + ); + } + + if (flow.name === 'running') { + const minutes = Math.floor(flow.elapsed / 60); + const seconds = flow.elapsed % 60; + const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; + + return ( + + + + + Agent: {flow.config.agent} + {' '} + Evaluator(s):{' '} + {flow.config.evaluators.map(e => (e.includes('/') ? e.split('/').pop()! : e)).join(', ')} + {' '} + ({timeStr}) + + + + + + ); + } + + if (flow.name === 'results') { + return ( + setFlow({ name: 'loading' })} + onExit={onExit} + /> + ); + } + + return ( + setFlow({ name: 'loading' })} + onExit={onExit} + /> + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Results view +// ───────────────────────────────────────────────────────────────────────────── + +interface ResultsViewProps { + result: RunRecommendationCommandResult; + config: RecommendationWizardConfig; + filePath?: string; + onRunAnother: () => void; + onExit: () => void; +} + +function ResultsView({ result, config, filePath, onRunAnother, onExit }: ResultsViewProps) { + const [applyStatus, setApplyStatus] = useState<{ applied: boolean; message: string } | null>(null); + + const isConfigBundle = config.inputSource === 'config-bundle' && config.bundleName; + const hasNewVersion = + !!result.result?.systemPromptRecommendationResult?.configurationBundle || + !!result.result?.toolDescriptionRecommendationResult?.configurationBundle; + const canApply = isConfigBundle && hasNewVersion && result.region && !applyStatus; + + const actions = [ + ...(canApply ? [{ id: 'apply', title: 'Sync new bundle version to local config' }] : []), + { id: 'another', title: 'Run another recommendation' }, + { id: 'back', title: 'Back' }, + ]; + + const handleApply = useCallback(async () => { + if (!result.result || !result.region) return; + try { + const applyResult = await applyRecommendationToBundle({ + bundleArn: config.bundleName, // TUI stores ARN in bundleName + result: result.result, + region: result.region, + }); + if (applyResult.success) { + setApplyStatus({ + applied: true, + message: `New bundle version (${applyResult.newVersionId}) created with recommended changes. Local config updated.`, + }); + } else { + setApplyStatus({ applied: false, message: applyResult.error ?? 'Unknown error' }); + } + } catch (err) { + setApplyStatus({ applied: false, message: getErrorMessage(err) }); + } + }, [result, config]); + + const nav = useListNavigation({ + items: actions, + onSelect: item => { + if (item.id === 'apply') void handleApply(); + else if (item.id === 'another') onRunAnother(); + else onExit(); + }, + onExit, + isActive: true, + }); + + const sysResult = result.result?.systemPromptRecommendationResult; + const toolResult = result.result?.toolDescriptionRecommendationResult; + + return ( + + + + ✓ Recommendation complete + + ID: {result.recommendationId} + {' '} + Agent: {config.agent} + + + {sysResult && ( + + {sysResult.recommendedSystemPrompt && ( + + + Recommended System Prompt: + + + {sysResult.recommendedSystemPrompt} + + + )} + + )} + + {toolResult?.tools && toolResult.tools.length > 0 && ( + + + Recommended Tool Descriptions: + + {toolResult.tools.map(tool => ( + + {tool.toolName} + {tool.recommendedToolDescription} + + ))} + + )} + + {!sysResult && !toolResult && ( + + No recommendation results returned. + + )} + + {filePath && ( + + Results saved to: {filePath} + + )} + + {applyStatus && ( + + {applyStatus.applied ? ( + ✓ {applyStatus.message} + ) : ( + Could not sync: {applyStatus.message} + )} + + )} + + + {actions.map((action, idx) => { + const selected = idx === nav.selectedIndex; + return ( + + {selected ? '❯' : ' '} + + {action.title} + + + ); + })} + + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function buildAgentItems(deployedState: DeployedState): AgentItem[] { + const agents: AgentItem[] = []; + const seen = new Set(); + + for (const target of Object.values(deployedState.targets)) { + const runtimeMap = target.resources?.runtimes; + if (!runtimeMap) continue; + for (const [name, state] of Object.entries(runtimeMap)) { + if (seen.has(name)) continue; + seen.add(name); + agents.push({ name, runtimeId: state.runtimeId, runtimeArn: state.runtimeArn }); + } + } + + return agents; +} + +/** + * Recursively collect all string-valued leaf fields from an object. + * Returns entries with their full dot-notation path and JSONPath equivalent. + * + * The recommendation API resolves JSONPath against the components map directly, + * using dot notation: `$.{componentArn}.configuration.{fieldName}` + */ +function collectStringFields(obj: unknown, prefix: string, jsonPathPrefix: string): ConfigBundleField[] { + const fields: ConfigBundleField[] = []; + if (obj === null || obj === undefined || typeof obj !== 'object') return fields; + + for (const [key, value] of Object.entries(obj as Record)) { + const path = prefix ? `${prefix}.${key}` : key; + const jp = jsonPathPrefix ? `${jsonPathPrefix}.${key}` : key; + if (typeof value === 'string' && value.trim().length > 0) { + fields.push({ path, jsonPath: jp, value }); + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + fields.push(...collectStringFields(value, path, jp)); + } + } + + return fields; +} + +function buildConfigBundleItems( + deployedState: DeployedState, + projectBundles: { name: string; components?: Record }> }[] +): ConfigBundleItem[] { + const bundles: ConfigBundleItem[] = []; + const seen = new Set(); + + for (const target of Object.values(deployedState.targets)) { + const bundleMap = target.resources?.configBundles; + if (!bundleMap) continue; + for (const [name, state] of Object.entries(bundleMap)) { + if (seen.has(name)) continue; + seen.add(name); + + const projBundle = projectBundles.find(pb => pb.name === name); + const fields = projBundle?.components ? collectStringFields(projBundle.components, '', '$') : []; + + bundles.push({ + name, + bundleId: state.bundleId, + bundleArn: state.bundleArn, + versionId: state.versionId, + fields, + }); + } + } + + return bundles; +} diff --git a/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx new file mode 100644 index 000000000..e1ee7e9d9 --- /dev/null +++ b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx @@ -0,0 +1,250 @@ +import type { RecommendationRunRecord } from '../../../operations/recommendation/recommendation-storage'; +import { listAllRecommendations } from '../../../operations/recommendation/recommendation-storage'; +import { Panel, Screen } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { Box, Text, useInput, useStdout } from 'ink'; +import React, { useMemo, useState } from 'react'; + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function formatShortDate(timestamp: string): string { + const d = new Date(timestamp); + const mon = MONTHS[d.getMonth()]; + const day = d.getDate(); + const h = d.getHours(); + const m = d.getMinutes().toString().padStart(2, '0'); + const ampm = h >= 12 ? 'PM' : 'AM'; + const h12 = h % 12 || 12; + return `${mon} ${day} ${h12}:${m} ${ampm}`; +} + +function shortTypeName(type: string): string { + if (type === 'SYSTEM_PROMPT_RECOMMENDATION') return 'System Prompt'; + if (type === 'TOOL_DESCRIPTION_RECOMMENDATION') return 'Tool Description'; + return type; +} + +function statusColor(status: string): string { + if (status === 'COMPLETED' || status === 'SUCCEEDED') return 'green'; + if (status === 'FAILED') return 'red'; + if (status === 'IN_PROGRESS' || status === 'PENDING') return 'yellow'; + return 'gray'; +} + +const CHROME_LINES = 9; + +// ───────────────────────────────────────────────────────────────────────────── +// List view +// ───────────────────────────────────────────────────────────────────────────── + +function RecommendationListView({ + records, + onSelect, + onExit, + availableHeight, +}: { + records: RecommendationRunRecord[]; + onSelect: (record: RecommendationRunRecord) => void; + onExit: () => void; + availableHeight: number; +}) { + const nav = useListNavigation({ + items: records, + onSelect: item => onSelect(item), + onExit, + isActive: true, + }); + + const maxVisible = Math.max(1, availableHeight - 3); + const visible = useMemo(() => { + let start = 0; + if (nav.selectedIndex >= maxVisible) { + start = nav.selectedIndex - maxVisible + 1; + } + return { items: records.slice(start, start + maxVisible), startIdx: start }; + }, [records, nav.selectedIndex, maxVisible]); + + return ( + + + Recommendation History + + {records.length} recommendation{records.length !== 1 ? 's' : ''} + + + {visible.items.map((rec, vIdx) => { + const idx = visible.startIdx + vIdx; + const selected = idx === nav.selectedIndex; + const date = rec.startedAt ? formatShortDate(rec.startedAt) : 'unknown'; + + return ( + + {selected ? '❯' : ' '} + {date.padEnd(16)} + {rec.status.padEnd(12)} + {shortTypeName(rec.type).padEnd(18)} + {rec.agent} + + ); + })} + {visible.startIdx + maxVisible < records.length && ( + ↓ {records.length - visible.startIdx - maxVisible} more + )} + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Detail view +// ───────────────────────────────────────────────────────────────────────────── + +function RecommendationDetailView({ record, onBack }: { record: RecommendationRunRecord; onBack: () => void }) { + useInput((input, key) => { + if (key.escape || input === 'b') { + onBack(); + } + }); + + const sysResult = record.result?.systemPromptRecommendationResult; + const toolResult = record.result?.toolDescriptionRecommendationResult; + + return ( + + + + ID: {record.recommendationId} + + + Type: {shortTypeName(record.type)} + {' '} + Agent: {record.agent} + {' '} + Status: {record.status} + + + Evaluators: {record.evaluators.join(', ')} + + {record.startedAt && ( + + Started: {new Date(record.startedAt).toLocaleString()} + + )} + {record.completedAt && ( + + Completed: {new Date(record.completedAt).toLocaleString()} + + )} + + {sysResult && ( + + {sysResult.recommendedSystemPrompt && ( + + + Recommended System Prompt: + + + {sysResult.recommendedSystemPrompt} + + + )} + + )} + + {toolResult?.tools && toolResult.tools.length > 0 && ( + + + Recommended Tool Descriptions: + + {toolResult.tools.map(tool => ( + + {tool.toolName} + {tool.recommendedToolDescription} + + ))} + + )} + + {!sysResult && !toolResult && ( + + No recommendation results available. + + )} + + + Press Esc or B to go back + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main screen +// ───────────────────────────────────────────────────────────────────────────── + +interface RecommendationHistoryScreenProps { + onExit: () => void; +} + +export function RecommendationHistoryScreen({ onExit }: RecommendationHistoryScreenProps) { + const { stdout } = useStdout(); + const terminalHeight = stdout?.rows ?? 24; + const availableHeight = Math.max(6, terminalHeight - CHROME_LINES); + + const [selectedRecord, setSelectedRecord] = useState(null); + + const [records, loaded, error] = useMemo(() => { + try { + return [listAllRecommendations(), true, null] as const; + } catch (err) { + return [[] as RecommendationRunRecord[], true, err instanceof Error ? err.message : String(err)] as const; + } + }, []); + + if (!loaded) { + return ( + + Loading... + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (records.length === 0) { + return ( + + + No recommendation runs found. + Run `agentcore run recommendation` to create one. + + + ); + } + + const helpText = selectedRecord ? 'Esc/B back to list' : HELP_TEXT.NAVIGATE_SELECT; + + return ( + + {selectedRecord ? ( + setSelectedRecord(null)} /> + ) : ( + + )} + + ); +} diff --git a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx new file mode 100644 index 000000000..adac72ef8 --- /dev/null +++ b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx @@ -0,0 +1,599 @@ +import { detectRegion } from '../../../aws/region'; +import type { SessionInfo } from '../../../operations/eval'; +import { discoverSessions } from '../../../operations/eval'; +import { loadDeployedProjectConfig, resolveAgent } from '../../../operations/resolve-agent'; +import type { SelectableItem } from '../../components'; +import { + ConfirmReview, + GradientText, + Panel, + PathInput, + Screen, + StepIndicator, + TextInput, + WizardMultiSelect, + WizardSelect, +} from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; +import type { AgentItem, ConfigBundleItem, EvaluatorItem, RecommendationWizardConfig } from './types'; +import { DEFAULT_LOOKBACK_DAYS, RECOMMENDATION_STEP_LABELS } from './types'; +import { useRecommendationWizard } from './useRecommendationWizard'; +import { Box, Text } from 'ink'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +interface RecommendationScreenProps { + agents: AgentItem[]; + evaluators: EvaluatorItem[]; + configBundles: ConfigBundleItem[]; + onComplete: (config: RecommendationWizardConfig) => void; + onExit: () => void; +} + +export function RecommendationScreen({ + agents, + evaluators, + configBundles, + onComplete, + onExit, +}: RecommendationScreenProps) { + const wizard = useRecommendationWizard(); + + // ── Selectable items ────────────────────────────────────────────────────── + + const typeItems: SelectableItem[] = useMemo( + () => [ + { + id: 'SYSTEM_PROMPT_RECOMMENDATION', + title: 'System Prompt', + description: "Optimize your agent's system prompt based on traces", + }, + { + id: 'TOOL_DESCRIPTION_RECOMMENDATION', + title: 'Tool Description', + description: 'Optimize tool descriptions for better tool selection', + }, + ], + [] + ); + + const agentItems: SelectableItem[] = useMemo( + () => + agents.map(a => ({ + id: a.name, + title: a.name, + description: `Runtime: ${a.runtimeId}`, + })), + [agents] + ); + + const evaluatorItems: SelectableItem[] = useMemo( + () => + evaluators.map(e => ({ + id: e.id, + title: e.title, + description: e.description, + })), + [evaluators] + ); + + const isToolDesc = wizard.config.type === 'TOOL_DESCRIPTION_RECOMMENDATION'; + + const inputSourceItems: SelectableItem[] = useMemo( + () => + isToolDesc + ? [ + { id: 'inline', title: 'Enter inline', description: 'Type tool name:description pairs directly' }, + { + id: 'config-bundle', + title: 'Config bundle', + description: 'Read tool descriptions from a deployed config bundle', + }, + ] + : [ + { id: 'inline', title: 'Enter inline', description: 'Type or paste content directly' }, + { id: 'file', title: 'Load from file', description: 'Read content from a file path' }, + { + id: 'config-bundle', + title: 'Config bundle', + description: 'Use system prompt from a deployed config bundle', + }, + ], + [isToolDesc] + ); + + const traceSourceItems: SelectableItem[] = useMemo( + () => [ + { id: 'cloudwatch', title: 'CloudWatch Logs', description: 'Discover traces from agent runtime logs' }, + { id: 'sessions', title: 'Session IDs', description: 'Provide specific session IDs manually' }, + ], + [] + ); + + // ── Session discovery ────────────────────────────────────────────────────── + + type SessionResult = { phase: 'loaded'; sessions: SessionInfo[] } | { phase: 'error'; message: string }; + + const [sessionResult, setSessionResult] = useState(); + const fetchingRef = useRef(''); + + // ── Step flags ──────────────────────────────────────────────────────────── + + const isTypeStep = wizard.step === 'type'; + const isAgentStep = wizard.step === 'agent'; + const isEvaluatorStep = wizard.step === 'evaluator'; + const isInputSourceStep = wizard.step === 'inputSource'; + const isContentStep = wizard.step === 'content'; + const isBundleStep = wizard.step === 'bundle'; + const isBundleFieldStep = wizard.step === 'bundleField'; + const isToolsStep = wizard.step === 'tools'; + const isTraceSourceStep = wizard.step === 'traceSource'; + const isDaysStep = wizard.step === 'days'; + const isSessionsStep = wizard.step === 'sessions'; + const isConfirmStep = wizard.step === 'confirm'; + + const isSystemPrompt = wizard.config.type === 'SYSTEM_PROMPT_RECOMMENDATION'; + + // ── Session discovery effect ────────────────────────────────────────────── + + const fetchKey = `${wizard.config.agent}:${wizard.config.days}`; + const sessionPhase = !isSessionsStep ? 'idle' : sessionResult?.key === fetchKey ? sessionResult.phase : 'loading'; + + useEffect(() => { + if (!isSessionsStep) return; + if (sessionResult?.key === fetchKey) return; + if (fetchingRef.current === fetchKey) return; + fetchingRef.current = fetchKey; + let cancelled = false; + + void (async () => { + try { + const context = await loadDeployedProjectConfig(); + const { region } = await detectRegion(); + const agentResult = resolveAgent(context, { runtime: wizard.config.agent }); + if (!agentResult.success) { + if (!cancelled) setSessionResult({ key: fetchKey, phase: 'error', message: agentResult.error }); + return; + } + + const sessions = await discoverSessions({ + runtimeId: agentResult.agent.runtimeId, + region, + lookbackDays: wizard.config.days, + }); + + if (cancelled) return; + + if (sessions.length === 0) { + setSessionResult({ + key: fetchKey, + phase: 'error', + message: 'No sessions found in the lookback window. Try increasing the lookback days.', + }); + } else { + setSessionResult({ key: fetchKey, phase: 'loaded', sessions }); + } + } catch (err) { + if (!cancelled) { + setSessionResult({ + key: fetchKey, + phase: 'error', + message: err instanceof Error ? err.message : 'Failed to discover sessions', + }); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [isSessionsStep, fetchKey]); // eslint-disable-line react-hooks/exhaustive-deps + + const sessionItems: SelectableItem[] = useMemo(() => { + const sessions = sessionResult?.phase === 'loaded' ? sessionResult.sessions : []; + return sessions.map(s => { + const date = s.firstSeen + ? new Date(s.firstSeen).toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : ''; + const shortId = s.sessionId.length > 36 ? s.sessionId.slice(0, 36) + '…' : s.sessionId; + return { + id: s.sessionId, + title: shortId, + description: `${s.spanCount} spans · ${date}`, + }; + }); + }, [sessionResult]); + + // ── Navigation hooks ────────────────────────────────────────────────────── + + const typeNav = useListNavigation({ + items: typeItems, + onSelect: item => wizard.setType(item.id as 'SYSTEM_PROMPT_RECOMMENDATION' | 'TOOL_DESCRIPTION_RECOMMENDATION'), + onExit, + isActive: isTypeStep, + }); + + const agentNav = useListNavigation({ + items: agentItems, + onSelect: item => wizard.setAgent(item.id), + onExit: () => wizard.goBack(), + isActive: isAgentStep, + }); + + const evaluatorNav = useListNavigation({ + items: evaluatorItems, + onSelect: item => wizard.setEvaluators([item.id]), + onExit: () => wizard.goBack(), + isActive: isEvaluatorStep, + }); + + const inputSourceNav = useListNavigation({ + items: inputSourceItems, + onSelect: item => wizard.setInputSource(item.id as 'inline' | 'file' | 'config-bundle'), + onExit: () => wizard.goBack(), + isActive: isInputSourceStep, + }); + + const bundleItems: SelectableItem[] = useMemo( + () => + configBundles.map(cb => ({ + id: cb.bundleArn, + title: cb.name, + description: `Version: ${cb.versionId.slice(0, 8)}`, + })), + [configBundles] + ); + + const bundleNav = useListNavigation({ + items: bundleItems, + onSelect: item => { + const cb = configBundles.find(b => b.bundleArn === item.id); + if (cb) wizard.setBundle(cb.bundleArn, cb.versionId); + }, + onExit: () => wizard.goBack(), + isActive: isBundleStep, + }); + + // Build selectable items from recursively-discovered fields in the selected config bundle + const selectedBundle = useMemo( + () => configBundles.find(cb => cb.bundleArn === wizard.config.bundleName), + [configBundles, wizard.config.bundleName] + ); + + const bundleFieldItems: SelectableItem[] = useMemo(() => { + if (!selectedBundle) return []; + return selectedBundle.fields.map(field => { + // Shorten display: strip the long component ARN key, keep the meaningful tail. + // "components.arn:aws:...:runtime/name.configuration.systemPrompt" → "configuration.systemPrompt" + const segments = field.path.split('.'); + const configIdx = segments.indexOf('configuration'); + const displayPath = configIdx >= 0 ? segments.slice(configIdx).join('.') : segments.slice(-2).join('.'); + return { + id: field.path, + title: displayPath, + description: field.value.length > 80 ? field.value.slice(0, 80) + '…' : field.value, + }; + }); + }, [selectedBundle]); + + // Single-select for: system prompt (always), or tool desc with only 1 field (just press Enter) + const useFieldSingleSelect = !isToolDesc || bundleFieldItems.length <= 1; + const bundleFieldNav = useListNavigation({ + items: bundleFieldItems, + onSelect: item => { + const field = selectedBundle?.fields.find(f => f.path === item.id); + if (!field) return; + wizard.setBundleFields([item.id], { systemPromptJsonPath: field.jsonPath }); + }, + onExit: () => wizard.goBack(), + isActive: isBundleFieldStep && useFieldSingleSelect, + }); + + // Tool description multi-select: only when there are 2+ fields to choose from + const bundleFieldMultiNav = useMultiSelectNavigation({ + items: bundleFieldItems, + getId: item => item.id, + onConfirm: ids => { + const toolDescJsonPaths = ids + .map(id => { + const field = selectedBundle?.fields.find(f => f.path === id); + if (!field) return undefined; + // Use the last segment of the path as the tool name + const toolName = field.path.split('.').pop()!; + return { toolName, toolDescriptionJsonPath: field.jsonPath }; + }) + .filter((p): p is { toolName: string; toolDescriptionJsonPath: string } => p !== undefined); + wizard.setBundleFields(ids, { toolDescJsonPaths }); + }, + onExit: () => wizard.goBack(), + isActive: isBundleFieldStep && !useFieldSingleSelect, + requireSelection: true, + }); + + const traceSourceNav = useListNavigation({ + items: traceSourceItems, + onSelect: item => wizard.setTraceSource(item.id as 'cloudwatch' | 'sessions'), + onExit: () => wizard.goBack(), + isActive: isTraceSourceStep, + }); + + // Handle Esc during session loading/error (when multi-select is not yet active) + useListNavigation({ + items: [{ id: 'back', title: 'Back' }], + onSelect: () => wizard.goBack(), + onExit: () => wizard.goBack(), + isActive: isSessionsStep && sessionPhase !== 'loaded', + }); + + const sessionsNav = useMultiSelectNavigation({ + items: sessionItems, + getId: item => item.id, + onConfirm: ids => wizard.setSessions(ids), + onExit: () => wizard.goBack(), + isActive: isSessionsStep && sessionPhase === 'loaded', + requireSelection: true, + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(wizard.config), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + // ── Help text ───────────────────────────────────────────────────────────── + + const helpText = isEvaluatorStep + ? HELP_TEXT.NAVIGATE_SELECT + : isSessionsStep + ? sessionPhase === 'loading' + ? '' + : sessionPhase === 'error' + ? HELP_TEXT.CONFIRM_CANCEL + : 'Space toggle · Enter confirm · Esc back' + : isBundleFieldStep && !useFieldSingleSelect + ? 'Space to select · Enter confirm · Esc back' + : isTypeStep || isAgentStep || isInputSourceStep || isTraceSourceStep || isBundleStep || isBundleFieldStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ( + + ); + + // ── Confirm fields ──────────────────────────────────────────────────────── + + const confirmFields = [ + { label: 'Type', value: isSystemPrompt ? 'System Prompt' : 'Tool Description' }, + { label: 'Agent', value: wizard.config.agent }, + ...(isSystemPrompt + ? [ + { + label: 'Evaluator', + value: + wizard.config.evaluators.map(e => (e.includes('/') ? e.split('/').pop()! : e)).join(', ') || '(none)', + }, + ] + : []), + { + label: 'Input', + value: + wizard.config.inputSource === 'file' + ? `File: ${wizard.config.content}` + : wizard.config.inputSource === 'config-bundle' + ? `Bundle: ${configBundles.find(b => b.bundleArn === wizard.config.bundleName)?.name ?? wizard.config.bundleName} (${wizard.config.bundleFields.length === 1 ? `field: ${wizard.config.bundleFields[0]}` : `fields: ${wizard.config.bundleFields.join(', ')}`})` + : 'Inline', + }, + { + label: 'Traces', + value: + wizard.config.traceSource === 'sessions' + ? `${wizard.config.sessionIds.length} session${wizard.config.sessionIds.length !== 1 ? 's' : ''} selected (auto-fetch)` + : `CloudWatch (${wizard.config.days}d)`, + }, + ]; + + if (!isSystemPrompt && wizard.config.inputSource !== 'config-bundle') { + confirmFields.push({ label: 'Tools', value: wizard.config.tools || '(none)' }); + } + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( + + + {isTypeStep && ( + + )} + + {isAgentStep && ( + + )} + + {isEvaluatorStep && ( + + )} + + {isInputSourceStep && ( + + )} + + {isContentStep && wizard.config.inputSource === 'inline' && ( + wizard.goBack()} + expandable + /> + )} + + {isContentStep && wizard.config.inputSource === 'file' && ( + wizard.goBack()} + placeholder="/path/to/prompt.txt" + pathType="file" + /> + )} + + {isBundleStep && configBundles.length === 0 && ( + + Select config bundle + + No deployed config bundles found. Run `agentcore add config-bundle` and `agentcore deploy` first. + + Press Esc to go back and choose a different input source. + + )} + + {isBundleStep && configBundles.length > 0 && ( + + )} + + {isBundleFieldStep && bundleFieldItems.length === 0 && ( + + Select field + No text fields found in this config bundle's configuration. + Press Esc to go back and choose a different bundle. + + )} + + {isBundleFieldStep && bundleFieldItems.length > 0 && useFieldSingleSelect && ( + + )} + + {isBundleFieldStep && bundleFieldItems.length > 0 && !useFieldSingleSelect && ( + + )} + + {isToolsStep && ( + + Enter tool names and descriptions as comma-separated toolName:description pairs. + wizard.goBack()} + expandable + /> + + )} + + {isTraceSourceStep && ( + + )} + + {isDaysStep && ( + + Note: Traces may take 5-10 min to appear after agent invocations. + { + const days = parseInt(value, 10); + if (!isNaN(days) && days >= 1 && days <= 90) { + wizard.setDays(days); + } + }} + onCancel={() => wizard.goBack()} + customValidation={value => { + const days = parseInt(value, 10); + if (isNaN(days)) return 'Must be a number'; + if (days < 1 || days > 90) return 'Must be between 1 and 90'; + return true; + }} + /> + + )} + + {isSessionsStep && sessionPhase === 'loading' && } + + {isSessionsStep && sessionResult?.phase === 'error' && {sessionResult.message}} + + {isSessionsStep && sessionPhase === 'loaded' && ( + + )} + + {isConfirmStep && } + + + ); +} diff --git a/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx new file mode 100644 index 000000000..2e53e0ebd --- /dev/null +++ b/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx @@ -0,0 +1,43 @@ +import { Screen, WizardSelect } from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import React, { useMemo } from 'react'; + +export type RecommendationsHubView = 'run-recommendation' | 'recommendation-history'; + +interface RecommendationsHubScreenProps { + onSelect: (view: RecommendationsHubView) => void; + onExit: () => void; +} + +export function RecommendationsHubScreen({ onSelect, onExit }: RecommendationsHubScreenProps) { + const items: SelectableItem[] = useMemo( + () => [ + { + id: 'run-recommendation', + title: 'Run Recommendation', + description: 'Optimize system prompts and tool descriptions using agent traces', + }, + { + id: 'recommendation-history', + title: 'Recommendation History', + description: 'View past recommendation results (local)', + }, + ], + [] + ); + + const nav = useListNavigation({ + items, + onSelect: item => onSelect(item.id as RecommendationsHubView), + onExit, + isActive: true, + }); + + return ( + + + + ); +} diff --git a/src/cli/tui/screens/recommendation/index.ts b/src/cli/tui/screens/recommendation/index.ts new file mode 100644 index 000000000..3c2e16fe7 --- /dev/null +++ b/src/cli/tui/screens/recommendation/index.ts @@ -0,0 +1,3 @@ +export { RecommendationFlow } from './RecommendationFlow'; +export { RecommendationHistoryScreen } from './RecommendationHistoryScreen'; +export { RecommendationsHubScreen } from './RecommendationsHubScreen'; diff --git a/src/cli/tui/screens/recommendation/types.ts b/src/cli/tui/screens/recommendation/types.ts new file mode 100644 index 000000000..587ea4a20 --- /dev/null +++ b/src/cli/tui/screens/recommendation/types.ts @@ -0,0 +1,86 @@ +import type { + RecommendationInputSourceKind, + RecommendationType, + TraceSourceKind, +} from '../../../operations/recommendation'; + +export type RecommendationStep = + | 'type' + | 'agent' + | 'evaluator' + | 'inputSource' + | 'content' + | 'bundle' + | 'bundleField' + | 'tools' + | 'traceSource' + | 'days' + | 'sessions' + | 'confirm'; + +export interface RecommendationWizardConfig { + type: RecommendationType; + agent: string; + evaluators: string[]; + inputSource: RecommendationInputSourceKind; + content: string; + tools: string; + traceSource: TraceSourceKind; + days: number; + sessionIds: string[]; + bundleName: string; + bundleVersion: string; + bundleFields: string[]; + /** JSONPath for system prompt within the config bundle (set when user picks a field) */ + systemPromptJsonPath: string; + /** Tool name → JSONPath pairs for tool descriptions within the config bundle */ + toolDescJsonPaths: { toolName: string; toolDescriptionJsonPath: string }[]; +} + +export const RECOMMENDATION_STEP_LABELS: Record = { + type: 'Type', + agent: 'Agent', + evaluator: 'Evaluator', + inputSource: 'Source', + content: 'Content', + bundle: 'Bundle', + bundleField: 'Fields', + tools: 'Tools', + traceSource: 'Traces', + days: 'Lookback', + sessions: 'Sessions', + confirm: 'Confirm', +}; + +export const DEFAULT_LOOKBACK_DAYS = 7; + +export interface AgentItem { + name: string; + runtimeId: string; + runtimeArn: string; +} + +export interface EvaluatorItem { + id: string; + title: string; + description: string; +} + +/** A string field found at an arbitrary depth inside a config bundle's JSON. */ +export interface ConfigBundleField { + /** Dot-notation path from the bundle root, e.g. "components.myAgent.configuration.systemPrompt" */ + path: string; + /** JSONPath expression for the API, e.g. "$.components.myAgent.configuration.systemPrompt" */ + jsonPath: string; + /** The string value at this path */ + value: string; +} + +export interface ConfigBundleItem { + name: string; + bundleId: string; + bundleArn: string; + versionId: string; + /** All string-valued fields found recursively across the bundle's components. */ + fields: ConfigBundleField[]; +} diff --git a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts new file mode 100644 index 000000000..94c3c66d1 --- /dev/null +++ b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts @@ -0,0 +1,232 @@ +import type { + RecommendationInputSourceKind, + RecommendationType, + TraceSourceKind, +} from '../../../operations/recommendation'; +import type { RecommendationStep, RecommendationWizardConfig } from './types'; +import { DEFAULT_LOOKBACK_DAYS } from './types'; +import { useCallback, useState } from 'react'; + +function getAllSteps( + type: RecommendationType, + inputSource: RecommendationInputSourceKind, + traceSource: TraceSourceKind +): RecommendationStep[] { + const steps: RecommendationStep[] = ['type', 'agent']; + + // Evaluator step only for system prompt recommendations (tool desc API does not accept evaluators) + if (type === 'SYSTEM_PROMPT_RECOMMENDATION') { + steps.push('evaluator'); + } + + // Input source selection (both types support inline and config-bundle) + steps.push('inputSource'); + + if (type === 'SYSTEM_PROMPT_RECOMMENDATION') { + if (inputSource === 'inline' || inputSource === 'file') { + steps.push('content'); + } else if (inputSource === 'config-bundle') { + steps.push('bundle'); + steps.push('bundleField'); + } + } else { + // TOOL_DESCRIPTION_RECOMMENDATION + if (inputSource === 'config-bundle') { + steps.push('bundle'); + steps.push('bundleField'); + } else { + steps.push('tools'); + } + } + + steps.push('traceSource'); + + if (traceSource === 'sessions') { + // When using session IDs: ask lookback days first (for discovery), then select sessions + steps.push('days'); + steps.push('sessions'); + } else { + // CloudWatch: just ask lookback days + steps.push('days'); + } + + steps.push('confirm'); + return steps; +} + +function getDefaultConfig(): RecommendationWizardConfig { + return { + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: '', + evaluators: [], + inputSource: 'inline', + content: '', + tools: '', + traceSource: 'cloudwatch', + days: DEFAULT_LOOKBACK_DAYS, + sessionIds: [], + bundleName: '', + bundleVersion: '', + bundleFields: [], + systemPromptJsonPath: '', + toolDescJsonPaths: [], + }; +} + +export function useRecommendationWizard() { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState('type'); + + const allSteps = getAllSteps(config.type, config.inputSource, config.traceSource); + const currentIndex = allSteps.indexOf(step); + + const advance = useCallback( + ( + fromStep: RecommendationStep, + overrides?: { + type?: RecommendationType; + inputSource?: RecommendationInputSourceKind; + traceSource?: TraceSourceKind; + } + ) => { + const steps = getAllSteps( + overrides?.type ?? config.type, + overrides?.inputSource ?? config.inputSource, + overrides?.traceSource ?? config.traceSource + ); + const idx = steps.indexOf(fromStep); + const next = steps[idx + 1]; + if (next) setStep(next); + }, + [config.type, config.inputSource, config.traceSource] + ); + + const goBack = useCallback(() => { + const prevStep = allSteps[currentIndex - 1]; + if (prevStep) setStep(prevStep); + }, [allSteps, currentIndex]); + + const setType = useCallback( + (type: RecommendationType) => { + setConfig(c => ({ ...c, type })); + advance('type', { type }); + }, + [advance] + ); + + const setAgent = useCallback( + (agent: string) => { + setConfig(c => ({ ...c, agent })); + advance('agent'); + }, + [advance] + ); + + const setEvaluators = useCallback( + (evaluators: string[]) => { + setConfig(c => ({ ...c, evaluators })); + advance('evaluator'); + }, + [advance] + ); + + const setInputSource = useCallback( + (inputSource: RecommendationInputSourceKind) => { + setConfig(c => ({ ...c, inputSource })); + advance('inputSource', { inputSource }); + }, + [advance] + ); + + const setContent = useCallback( + (content: string) => { + setConfig(c => ({ ...c, content })); + advance('content'); + }, + [advance] + ); + + const setTools = useCallback( + (tools: string) => { + setConfig(c => ({ ...c, tools })); + advance('tools'); + }, + [advance] + ); + + const setTraceSource = useCallback( + (traceSource: TraceSourceKind) => { + setConfig(c => ({ ...c, traceSource })); + advance('traceSource', { traceSource }); + }, + [advance] + ); + + const setDays = useCallback( + (days: number) => { + setConfig(c => ({ ...c, days })); + advance('days'); + }, + [advance] + ); + + const setBundle = useCallback( + (bundleName: string, bundleVersion: string) => { + setConfig(c => ({ ...c, bundleName, bundleVersion })); + advance('bundle'); + }, + [advance] + ); + + const setBundleFields = useCallback( + ( + bundleFields: string[], + jsonPathInfo?: { + systemPromptJsonPath?: string; + toolDescJsonPaths?: { toolName: string; toolDescriptionJsonPath: string }[]; + } + ) => { + setConfig(c => ({ + ...c, + bundleFields, + ...(jsonPathInfo?.systemPromptJsonPath && { systemPromptJsonPath: jsonPathInfo.systemPromptJsonPath }), + ...(jsonPathInfo?.toolDescJsonPaths && { toolDescJsonPaths: jsonPathInfo.toolDescJsonPaths }), + })); + advance('bundleField'); + }, + [advance] + ); + + const setSessions = useCallback( + (sessionIds: string[]) => { + setConfig(c => ({ ...c, sessionIds })); + advance('sessions'); + }, + [advance] + ); + + const reset = useCallback(() => { + setConfig(getDefaultConfig()); + setStep('type'); + }, []); + + return { + config, + step, + steps: allSteps, + currentIndex, + goBack, + setType, + setAgent, + setEvaluators, + setInputSource, + setContent, + setBundle, + setBundleFields, + setTools, + setTraceSource, + setDays, + setSessions, + reset, + }; +} diff --git a/src/cli/tui/screens/remove/RemoveConfigBundleScreen.tsx b/src/cli/tui/screens/remove/RemoveConfigBundleScreen.tsx new file mode 100644 index 000000000..90299eb30 --- /dev/null +++ b/src/cli/tui/screens/remove/RemoveConfigBundleScreen.tsx @@ -0,0 +1,26 @@ +import type { RemovableConfigBundle } from '../../../primitives/ConfigBundlePrimitive'; +import { SelectScreen } from '../../components'; +import React from 'react'; + +interface RemoveConfigBundleScreenProps { + configBundles: RemovableConfigBundle[]; + onSelect: (bundleName: string) => void; + onExit: () => void; +} + +export function RemoveConfigBundleScreen({ configBundles, onSelect, onExit }: RemoveConfigBundleScreenProps) { + const items = configBundles.map(bundle => ({ + id: bundle.name, + title: bundle.name, + description: 'Configuration Bundle', + })); + + return ( + onSelect(item.id)} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index c8a9e77b3..7001fde27 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -1,7 +1,9 @@ import type { RemovableGatewayTarget, RemovalPreview } from '../../../operations/remove'; import { ErrorPrompt, Panel, Screen } from '../../components'; import { + useRemovableABTests, useRemovableAgents, + useRemovableConfigBundles, useRemovableEvaluators, useRemovableGatewayTargets, useRemovableGateways, @@ -12,7 +14,9 @@ import { useRemovablePolicyEngines, useRemovableRuntimeEndpoints, useRemovalPreview, + useRemoveABTest, useRemoveAgent, + useRemoveConfigBundle, useRemoveEvaluator, useRemoveGateway, useRemoveGatewayTarget, @@ -23,8 +27,10 @@ import { useRemovePolicyEngine, useRemoveRuntimeEndpoint, } from '../../hooks/useRemove'; +import { RemoveABTestScreen } from '../ab-test/RemoveABTestScreen'; import { RemoveAgentScreen } from './RemoveAgentScreen'; import { RemoveAllScreen } from './RemoveAllScreen'; +import { RemoveConfigBundleScreen } from './RemoveConfigBundleScreen'; import { RemoveConfirmScreen } from './RemoveConfirmScreen'; import { RemoveEvaluatorScreen } from './RemoveEvaluatorScreen'; import { RemoveGatewayScreen } from './RemoveGatewayScreen'; @@ -53,6 +59,8 @@ type FlowState = | { name: 'select-online-eval' } | { name: 'select-policy-engine' } | { name: 'select-policy' } + | { name: 'select-config-bundle' } + | { name: 'select-ab-test' } | { name: 'select-runtime-endpoint' } | { name: 'confirm-agent'; agentName: string; preview: RemovalPreview } | { name: 'confirm-gateway'; gatewayName: string; preview: RemovalPreview } @@ -63,6 +71,8 @@ type FlowState = | { name: 'confirm-online-eval'; configName: string; preview: RemovalPreview } | { name: 'confirm-policy-engine'; engineName: string; preview: RemovalPreview } | { name: 'confirm-policy'; compositeKey: string; policyName: string; preview: RemovalPreview } + | { name: 'confirm-config-bundle'; bundleName: string; preview: RemovalPreview } + | { name: 'confirm-ab-test'; testName: string; preview: RemovalPreview } | { name: 'confirm-runtime-endpoint'; endpointName: string; preview: RemovalPreview } | { name: 'loading'; message: string } | { name: 'agent-success'; agentName: string; logFilePath?: string } @@ -74,6 +84,8 @@ type FlowState = | { name: 'online-eval-success'; configName: string; logFilePath?: string } | { name: 'policy-engine-success'; engineName: string; logFilePath?: string } | { name: 'policy-success'; policyName: string; logFilePath?: string } + | { name: 'config-bundle-success'; bundleName: string; logFilePath?: string } + | { name: 'ab-test-success'; testName: string; logFilePath?: string } | { name: 'runtime-endpoint-success'; endpointName: string; logFilePath?: string } | { name: 'remove-all' } | { name: 'error'; message: string }; @@ -97,7 +109,9 @@ interface RemoveFlowProps { | 'evaluator' | 'online-eval' | 'policy-engine' - | 'policy'; + | 'policy' + | 'config-bundle' + | 'ab-test'; /** Initial resource name to auto-select (for CLI --name flag) */ initialResourceName?: string; } @@ -131,6 +145,10 @@ export function RemoveFlow({ return { name: 'select-policy-engine' }; case 'policy': return { name: 'select-policy' }; + case 'config-bundle': + return { name: 'select-config-bundle' }; + case 'ab-test': + return { name: 'select-ab-test' }; case 'runtime-endpoint': return { name: 'select-runtime-endpoint' }; default: @@ -157,6 +175,12 @@ export function RemoveFlow({ refresh: refreshPolicyEngines, } = useRemovablePolicyEngines(); const { policies, isLoading: isLoadingPolicies, refresh: refreshPolicies } = useRemovablePolicies(); + const { + configBundles, + isLoading: isLoadingConfigBundles, + refresh: refreshConfigBundles, + } = useRemovableConfigBundles(); + const { abTests } = useRemovableABTests(); const { endpoints: runtimeEndpoints, isLoading: isLoadingRuntimeEndpoints, @@ -174,6 +198,7 @@ export function RemoveFlow({ isLoadingOnlineEvals || isLoadingPolicyEngines || isLoadingPolicies || + isLoadingConfigBundles || isLoadingRuntimeEndpoints; // Preview hook @@ -187,6 +212,8 @@ export function RemoveFlow({ loadOnlineEvalPreview, loadPolicyEnginePreview, loadPolicyPreview, + loadConfigBundlePreview, + loadABTestPreview, loadRuntimeEndpointPreview, reset: resetPreview, } = useRemovalPreview(); @@ -201,6 +228,8 @@ export function RemoveFlow({ const { remove: removeOnlineEvalOp, reset: resetRemoveOnlineEval } = useRemoveOnlineEvalConfig(); const { remove: removePolicyEngineOp, reset: resetRemovePolicyEngine } = useRemovePolicyEngine(); const { remove: removePolicyOp, reset: resetRemovePolicy } = useRemovePolicy(); + const { remove: removeConfigBundleOp, reset: resetRemoveConfigBundle } = useRemoveConfigBundle(); + const { remove: removeABTestOp, reset: resetRemoveABTest } = useRemoveABTest(); const { remove: removeRuntimeEndpointOp, reset: resetRemoveRuntimeEndpoint } = useRemoveRuntimeEndpoint(); // Track pending result state @@ -232,6 +261,8 @@ export function RemoveFlow({ 'online-eval-success', 'policy-engine-success', 'policy-success', + 'config-bundle-success', + 'ab-test-success', 'runtime-endpoint-success', ]; if (successStates.includes(flow.name)) { @@ -272,6 +303,12 @@ export function RemoveFlow({ case 'policy': setFlow({ name: 'select-policy' }); break; + case 'config-bundle': + setFlow({ name: 'select-config-bundle' }); + break; + case 'ab-test': + setFlow({ name: 'select-ab-test' }); + break; case 'runtime-endpoint': setFlow({ name: 'select-runtime-endpoint' }); break; @@ -485,6 +522,50 @@ export function RemoveFlow({ [loadPolicyPreview, force, removePolicyOp] ); + const handleSelectConfigBundle = useCallback( + async (bundleName: string) => { + const result = await loadConfigBundlePreview(bundleName); + if (result.ok) { + if (force) { + setFlow({ name: 'loading', message: `Removing configuration bundle ${bundleName}...` }); + const removeResult = await removeConfigBundleOp(bundleName, result.preview); + if (removeResult.success) { + setFlow({ name: 'config-bundle-success', bundleName }); + } else { + setFlow({ name: 'error', message: removeResult.error }); + } + } else { + setFlow({ name: 'confirm-config-bundle', bundleName, preview: result.preview }); + } + } else { + setFlow({ name: 'error', message: result.error }); + } + }, + [loadConfigBundlePreview, force, removeConfigBundleOp] + ); + + const handleSelectABTest = useCallback( + async (testName: string) => { + const result = await loadABTestPreview(testName); + if (result.ok) { + if (force) { + setFlow({ name: 'loading', message: `Removing AB test ${testName}...` }); + const removeResult = await removeABTestOp(testName, result.preview); + if (removeResult.success) { + setFlow({ name: 'ab-test-success', testName }); + } else { + setFlow({ name: 'error', message: removeResult.error }); + } + } else { + setFlow({ name: 'confirm-ab-test', testName, preview: result.preview }); + } + } else { + setFlow({ name: 'error', message: result.error }); + } + }, + [loadABTestPreview, force, removeABTestOp] + ); + const handleSelectRuntimeEndpoint = useCallback( async (endpointName: string) => { const result = await loadRuntimeEndpointPreview(endpointName); @@ -543,6 +624,12 @@ export function RemoveFlow({ case 'policy': void handleSelectPolicy(initialResourceName); break; + case 'config-bundle': + void handleSelectConfigBundle(initialResourceName); + break; + case 'ab-test': + void handleSelectABTest(initialResourceName); + break; case 'runtime-endpoint': void handleSelectRuntimeEndpoint(initialResourceName); break; @@ -560,6 +647,8 @@ export function RemoveFlow({ handleSelectOnlineEval, handleSelectPolicyEngine, handleSelectPolicy, + handleSelectConfigBundle, + handleSelectABTest, handleSelectRuntimeEndpoint, ]); @@ -708,6 +797,38 @@ export function RemoveFlow({ [removePolicyOp] ); + const handleConfirmConfigBundle = useCallback( + async (bundleName: string, preview: RemovalPreview) => { + pendingResultRef.current = null; + setResultReady(false); + setFlow({ name: 'loading', message: `Removing configuration bundle ${bundleName}...` }); + const result = await removeConfigBundleOp(bundleName, preview); + if (result.success) { + pendingResultRef.current = { name: 'config-bundle-success', bundleName, logFilePath: result.logFilePath }; + } else { + pendingResultRef.current = { name: 'error', message: result.error }; + } + setResultReady(true); + }, + [removeConfigBundleOp] + ); + + const handleConfirmABTest = useCallback( + async (testName: string, preview: RemovalPreview) => { + pendingResultRef.current = null; + setResultReady(false); + setFlow({ name: 'loading', message: `Removing AB test ${testName}...` }); + const result = await removeABTestOp(testName, preview); + if (result.success) { + pendingResultRef.current = { name: 'ab-test-success', testName, logFilePath: result.logFilePath }; + } else { + pendingResultRef.current = { name: 'error', message: result.error }; + } + setResultReady(true); + }, + [removeABTestOp] + ); + const handleConfirmRuntimeEndpoint = useCallback( async (endpointName: string, preview: RemovalPreview) => { pendingResultRef.current = null; @@ -735,6 +856,8 @@ export function RemoveFlow({ resetRemoveOnlineEval(); resetRemovePolicyEngine(); resetRemovePolicy(); + resetRemoveConfigBundle(); + resetRemoveABTest(); resetRemoveRuntimeEndpoint(); }, [ resetPreview, @@ -747,6 +870,8 @@ export function RemoveFlow({ resetRemoveOnlineEval, resetRemovePolicyEngine, resetRemovePolicy, + resetRemoveConfigBundle, + resetRemoveABTest, resetRemoveRuntimeEndpoint, ]); @@ -761,6 +886,7 @@ export function RemoveFlow({ refreshOnlineEvals(), refreshPolicyEngines(), refreshPolicies(), + refreshConfigBundles(), refreshRuntimeEndpoints(), ]); }, [ @@ -773,6 +899,7 @@ export function RemoveFlow({ refreshOnlineEvals, refreshPolicyEngines, refreshPolicies, + refreshConfigBundles, refreshRuntimeEndpoints, ]); @@ -794,6 +921,8 @@ export function RemoveFlow({ onlineEvalCount={onlineEvalConfigs.length} policyEngineCount={policyEngines.length} policyCount={policies.length} + configBundleCount={configBundles.length} + abTestCount={abTests.length} runtimeEndpointCount={runtimeEndpoints.length} /> ); @@ -929,6 +1058,32 @@ export function RemoveFlow({ ); } + if (flow.name === 'select-config-bundle') { + if (initialResourceName && isLoading) { + return null; + } + return ( + void handleSelectConfigBundle(name)} + onExit={() => setFlow({ name: 'select' })} + /> + ); + } + + if (flow.name === 'select-ab-test') { + if (initialResourceName && isLoading) { + return null; + } + return ( + void handleSelectABTest(name)} + onExit={() => setFlow({ name: 'select' })} + /> + ); + } + if (flow.name === 'select-runtime-endpoint') { if (initialResourceName && isLoading) { return null; @@ -1042,6 +1197,28 @@ export function RemoveFlow({ ); } + if (flow.name === 'confirm-config-bundle') { + return ( + void handleConfirmConfigBundle(flow.bundleName, flow.preview)} + onCancel={() => setFlow({ name: 'select-config-bundle' })} + /> + ); + } + + if (flow.name === 'confirm-ab-test') { + return ( + void handleConfirmABTest(flow.testName, flow.preview)} + onCancel={() => setFlow({ name: 'select-ab-test' })} + /> + ); + } + if (flow.name === 'confirm-runtime-endpoint') { return ( { + resetAll(); + void refreshAll().then(() => setFlow({ name: 'select' })); + }} + onExit={onExit} + /> + ); + } + + if (flow.name === 'ab-test-success') { + return ( + { + resetAll(); + void refreshAll().then(() => setFlow({ name: 'select' })); + }} + onExit={onExit} + /> + ); + } + if (flow.name === 'runtime-endpoint-success') { return ( { resetAll(); setFlow({ name: 'select' }); diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index 83488ed3a..b1178e530 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -12,6 +12,8 @@ const REMOVE_RESOURCES = [ { id: 'policy', title: 'Policy', description: 'Remove a policy from a policy engine' }, { id: 'gateway', title: 'Gateway', description: 'Remove a gateway' }, { id: 'gateway-target', title: 'Gateway Target', description: 'Remove a gateway target' }, + { id: 'config-bundle', title: 'Configuration Bundle [preview]', description: 'Remove a configuration bundle' }, + { id: 'ab-test', title: 'AB Test [preview]', description: 'Remove an A/B test' }, { id: 'runtime-endpoint', title: 'Runtime Endpoint', description: 'Remove a runtime endpoint' }, { id: 'all', title: 'All', description: 'Reset entire agentcore project' }, ] as const; @@ -39,6 +41,10 @@ interface RemoveScreenProps { policyEngineCount: number; /** Number of policies available for removal */ policyCount: number; + /** Number of configuration bundles available for removal */ + configBundleCount: number; + /** Number of AB tests available for removal */ + abTestCount: number; /** Number of runtime endpoints available for removal */ runtimeEndpointCount: number; } @@ -55,6 +61,8 @@ export function RemoveScreen({ onlineEvalCount, policyEngineCount, policyCount, + configBundleCount, + abTestCount, runtimeEndpointCount, }: RemoveScreenProps) { const items: SelectableItem[] = useMemo(() => { @@ -117,6 +125,18 @@ export function RemoveScreen({ description = 'No policies to remove'; } break; + case 'config-bundle': + if (configBundleCount === 0) { + disabled = true; + description = 'No configuration bundles to remove'; + } + break; + case 'ab-test': + if (abTestCount === 0) { + disabled = true; + description = 'No AB tests to remove'; + } + break; case 'runtime-endpoint': if (runtimeEndpointCount === 0) { disabled = true; @@ -140,6 +160,8 @@ export function RemoveScreen({ onlineEvalCount, policyEngineCount, policyCount, + configBundleCount, + abTestCount, runtimeEndpointCount, ]); diff --git a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx index 16e0223e3..ccc59e9da 100644 --- a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -21,6 +21,8 @@ describe('RemoveScreen', () => { onlineEvalCount={1} policyEngineCount={1} policyCount={1} + configBundleCount={1} + abTestCount={0} runtimeEndpointCount={1} /> ); @@ -52,6 +54,8 @@ describe('RemoveScreen', () => { onlineEvalCount={0} policyEngineCount={0} policyCount={0} + configBundleCount={0} + abTestCount={0} runtimeEndpointCount={0} /> ); @@ -61,4 +65,57 @@ describe('RemoveScreen', () => { expect(lastFrame()).toContain('No policy engines to remove'); expect(lastFrame()).toContain('No policies to remove'); }); + + it('AB test option enabled when abTestCount > 0', () => { + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('AB Test'); + expect(lastFrame()).not.toContain('No AB tests to remove'); + }); + + it('AB test option disabled when abTestCount = 0', () => { + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('No AB tests to remove'); + }); }); diff --git a/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx b/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx new file mode 100644 index 000000000..a1903f7d0 --- /dev/null +++ b/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx @@ -0,0 +1,307 @@ +import type { BatchEvalRunRecord } from '../../../operations/eval/batch-eval-storage'; +import { listBatchEvalRuns } from '../../../operations/eval/batch-eval-storage'; +import { Panel, Screen } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { Box, Text, useInput, useStdout } from 'ink'; +import React, { useMemo, useState } from 'react'; + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function formatShortDate(timestamp: string): string { + const d = new Date(timestamp); + const mon = MONTHS[d.getMonth()]; + const day = d.getDate(); + const h = d.getHours(); + const m = d.getMinutes().toString().padStart(2, '0'); + const ampm = h >= 12 ? 'PM' : 'AM'; + const h12 = h % 12 || 12; + return `${mon} ${day} ${h12}:${m} ${ampm}`; +} + +function statusColor(status: string): string { + if (status === 'COMPLETED' || status === 'SUCCEEDED') return 'green'; + if (status === 'FAILED') return 'red'; + if (status === 'IN_PROGRESS' || status === 'PENDING') return 'yellow'; + return 'gray'; +} + +function scoreColor(score: number): string { + if (score >= 0.8) return 'green'; + if (score >= 0.5) return 'yellow'; + return 'red'; +} + +const CHROME_LINES = 9; + +// ───────────────────────────────────────────────────────────────────────────── +// List view +// ───────────────────────────────────────────────────────────────────────────── + +function BatchEvalListView({ + records, + onSelect, + onExit, + availableHeight, +}: { + records: BatchEvalRunRecord[]; + onSelect: (record: BatchEvalRunRecord) => void; + onExit: () => void; + availableHeight: number; +}) { + const nav = useListNavigation({ + items: records, + onSelect: item => onSelect(item), + onExit, + isActive: true, + }); + + const maxVisible = Math.max(1, availableHeight - 3); + const visible = useMemo(() => { + let start = 0; + if (nav.selectedIndex >= maxVisible) { + start = nav.selectedIndex - maxVisible + 1; + } + return { items: records.slice(start, start + maxVisible), startIdx: start }; + }, [records, nav.selectedIndex, maxVisible]); + + return ( + + + Batch Evaluation History + + {records.length} batch evaluation{records.length !== 1 ? 's' : ''} + + + {visible.items.map((rec, vIdx) => { + const idx = visible.startIdx + vIdx; + const selected = idx === nav.selectedIndex; + const date = rec.startedAt ? formatShortDate(rec.startedAt) : 'unknown'; + + // Build a short score summary from evaluationResults or results + const summaries = rec.evaluationResults?.evaluatorSummaries; + let scoreText = ''; + if (summaries && summaries.length > 0) { + scoreText = summaries + .map(s => { + const avg = s.statistics?.averageScore; + return avg != null ? avg.toFixed(2) : 'N/A'; + }) + .join(', '); + } else if (rec.results.length > 0) { + const byEval = new Map(); + for (const r of rec.results) { + if (r.score != null) { + const scores = byEval.get(r.evaluatorId) ?? []; + scores.push(r.score); + byEval.set(r.evaluatorId, scores); + } + } + scoreText = [...byEval.entries()] + .map(([, scores]) => (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2)) + .join(', '); + } + + return ( + + {selected ? '>' : ' '} + {date.padEnd(16)} + {rec.status.padEnd(12)} + {scoreText && {scoreText.padEnd(10)}} + {rec.name} + + ); + })} + {visible.startIdx + maxVisible < records.length && ( + {records.length - visible.startIdx - maxVisible} more + )} + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Detail view +// ───────────────────────────────────────────────────────────────────────────── + +function BatchEvalDetailView({ record, onBack }: { record: BatchEvalRunRecord; onBack: () => void }) { + useInput((input, key) => { + if (key.escape || input === 'b') { + onBack(); + } + }); + + const evalRes = record.evaluationResults; + const summaries = evalRes?.evaluatorSummaries; + + // Fall back to local grouping when API summaries aren't available + const byEvaluator = useMemo(() => { + if (summaries && summaries.length > 0) return null; + const map = new Map(); + for (const r of record.results) { + const entry = map.get(r.evaluatorId) ?? { scores: [], errors: 0 }; + if (r.error) { + entry.errors++; + } else if (r.score != null) { + entry.scores.push(r.score); + } + map.set(r.evaluatorId, entry); + } + return map; + }, [record.results, summaries]); + + return ( + + + + ID: {record.batchEvaluationId} + + + Name: {record.name} + {' '} + Status: {record.status} + + + Evaluators: {record.evaluators.join(', ')} + + {record.startedAt && ( + + Started: {new Date(record.startedAt).toLocaleString()} + + )} + {record.completedAt && ( + + Completed: {new Date(record.completedAt).toLocaleString()} + + )} + + {evalRes?.totalNumberOfSessions != null && ( + + Sessions: {evalRes.totalNumberOfSessions} total + {evalRes.numberOfSessionsCompleted != null && , {evalRes.numberOfSessionsCompleted} completed} + {evalRes.numberOfSessionsFailed ? , {evalRes.numberOfSessionsFailed} failed : null} + + )} + + {summaries && summaries.length > 0 ? ( + + Scores (0 worst — 1 best): + {summaries.map(s => { + const avg = s.statistics?.averageScore; + const avgStr = avg != null ? avg.toFixed(2) : 'N/A'; + const color = avg != null ? scoreColor(avg) : undefined; + return ( + + {' '} + {s.evaluatorId} + {' '} + {avgStr} + {s.totalFailed ? ({s.totalFailed} failed) : null} + {s.totalEvaluated != null && [{s.totalEvaluated} evaluated]} + + ); + })} + + ) : byEvaluator && byEvaluator.size > 0 ? ( + + Scores (0 worst — 1 best): + {[...byEvaluator.entries()].map(([evalId, { scores, errors }]) => { + const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0; + return ( + + {' '} + {evalId} + {' '} + {avg.toFixed(2)} + {errors > 0 && ({errors} errors)} + + ); + })} + + ) : ( + + No evaluation results available. + + )} + + + Press Esc or B to go back + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main screen +// ───────────────────────────────────────────────────────────────────────────── + +interface BatchEvalHistoryScreenProps { + onExit: () => void; +} + +export function BatchEvalHistoryScreen({ onExit }: BatchEvalHistoryScreenProps) { + const { stdout } = useStdout(); + const terminalHeight = stdout?.rows ?? 24; + const availableHeight = Math.max(6, terminalHeight - CHROME_LINES); + + const [selectedRecord, setSelectedRecord] = useState(null); + + const [records, loaded, error] = useMemo(() => { + try { + return [listBatchEvalRuns(), true, null] as const; + } catch (err) { + return [[] as BatchEvalRunRecord[], true, err instanceof Error ? err.message : String(err)] as const; + } + }, []); + + if (!loaded) { + return ( + + Loading... + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (records.length === 0) { + return ( + + + No batch evaluation runs found. + Run a batch evaluation from the TUI or CLI to see results here. + + + ); + } + + const helpText = selectedRecord ? 'Esc/B back to list' : HELP_TEXT.NAVIGATE_SELECT; + + return ( + + {selectedRecord ? ( + setSelectedRecord(null)} /> + ) : ( + + )} + + ); +} diff --git a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx new file mode 100644 index 000000000..38c03c150 --- /dev/null +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -0,0 +1,968 @@ +import { validateAwsCredentials } from '../../../aws/account'; +import { stopBatchEvaluation } from '../../../aws/agentcore-batch-evaluation'; +import type { SessionMetadataEntry } from '../../../aws/agentcore-batch-evaluation'; +import { listEvaluators } from '../../../aws/agentcore-control'; +import { detectRegion } from '../../../aws/region'; +import { getErrorMessage } from '../../../errors'; +import type { SessionInfo } from '../../../operations/eval'; +import { discoverSessions } from '../../../operations/eval'; +import { saveBatchEvalRun } from '../../../operations/eval/batch-eval-storage'; +import { runBatchEvaluationCommand } from '../../../operations/eval/run-batch-evaluation'; +import type { + BatchEvaluationResult, + RunBatchEvaluationCommandResult, +} from '../../../operations/eval/run-batch-evaluation'; +import { loadDeployedProjectConfig, resolveAgent } from '../../../operations/resolve-agent'; +import { + ConfirmReview, + ErrorPrompt, + GradientText, + Panel, + PathInput, + Screen, + StepIndicator, + StepProgress, + TextInput, + WizardMultiSelect, + WizardSelect, +} from '../../components'; +import type { SelectableItem, Step } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; +import type { EvaluatorItem } from '../online-eval/types'; +import { GroundTruthForm } from './GroundTruthForm'; +import type { AgentItem } from './types'; +import type { GroundTruthData } from './useRunEvalWizard'; +import { Box, Text, useInput } from 'ink'; +import { readFileSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +// ============================================================================ +// Types +// ============================================================================ + +const DEFAULT_LOOKBACK_DAYS = 7; + +type BatchEvalStep = 'agent' | 'evaluators' | 'days' | 'sessions' | 'ground-truth' | 'name' | 'confirm'; + +interface BatchEvalConfig { + agent: string; + evaluators: string[]; + evaluatorNames: string[]; + days: number; + sessionIds: string[]; + groundTruthFile: string; + sessionMetadata?: SessionMetadataEntry[]; + name: string; +} + +const STEP_LABELS: Record = { + agent: 'Agent', + evaluators: 'Evaluators', + days: 'Lookback', + sessions: 'Sessions', + 'ground-truth': 'Ground Truth', + name: 'Name', + confirm: 'Confirm', +}; + +type FlowState = + | { name: 'loading' } + | { name: 'wizard'; agents: AgentItem[]; evaluators: EvaluatorItem[] } + | { + name: 'running'; + config: BatchEvalConfig; + steps: Step[]; + elapsed: number; + batchEvaluationId?: string; + region?: string; + } + | { name: 'results'; result: RunBatchEvaluationCommandResult; savedFilePath?: string } + | { name: 'creds-error'; message: string } + | { name: 'error'; message: string; logFilePath?: string }; + +// ============================================================================ +// Flow Component +// ============================================================================ + +interface RunBatchEvalFlowProps { + onExit: () => void; +} + +export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { + const [flow, setFlow] = useState({ name: 'loading' }); + const stoppingRef = useRef(false); + + // Handle Esc to stop a running batch evaluation + useInput((_input, key) => { + if (flow.name !== 'running' || !flow.batchEvaluationId || !flow.region || stoppingRef.current) return; + if (key.escape) { + stoppingRef.current = true; + void stopBatchEvaluation({ region: flow.region, batchEvaluationId: flow.batchEvaluationId }).catch(() => { + // Best-effort — the poll loop will pick up the final status + }); + setFlow(prev => { + if (prev.name !== 'running') return prev; + const steps = prev.steps.map(s => + s.status === 'running' ? { ...s, status: 'error' as const, error: 'Stopping...' } : s + ); + return { ...prev, steps }; + }); + } + }); + + // Load agents and evaluators + useEffect(() => { + if (flow.name !== 'loading') return; + let cancelled = false; + + void (async () => { + try { + await validateAwsCredentials(); + } catch (err) { + if (!cancelled) setFlow({ name: 'creds-error', message: getErrorMessage(err) }); + return; + } + + try { + const context = await loadDeployedProjectConfig(); + const targetRegion = context.awsTargets?.[0]?.region; + const { region: detectedRegion } = await detectRegion(); + const region = targetRegion ?? detectedRegion; + const evalResult = await listEvaluators({ region }); + + if (cancelled) return; + + const evaluators: EvaluatorItem[] = evalResult.evaluators.map(e => ({ + arn: e.evaluatorArn, + name: e.evaluatorName, + type: e.evaluatorType, + description: e.description, + })); + + // Only show deployed agents + const deployedAgentNames = new Set(); + for (const target of Object.values(context.deployedState.targets)) { + const runtimeStates = target.resources?.runtimes; + if (runtimeStates) { + for (const name of Object.keys(runtimeStates)) { + deployedAgentNames.add(name); + } + } + } + + const agents: AgentItem[] = context.project.runtimes + .filter((a: { name: string }) => deployedAgentNames.has(a.name)) + .map((a: { name: string; build: string }) => ({ name: a.name, build: a.build })); + + if (agents.length === 0) { + if (!cancelled) { + setFlow({ + name: 'error', + message: + context.project.runtimes.length === 0 + ? 'No agents found in project. Run `agentcore add agent` first.' + : 'No deployed agents found. Run `agentcore deploy` first.', + }); + } + return; + } + + if (evaluators.length === 0) { + if (!cancelled) { + setFlow({ name: 'error', message: 'No evaluators found in your account. Create an evaluator first.' }); + } + return; + } + + setFlow({ name: 'wizard', agents, evaluators }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [flow.name]); + + const handleWizardComplete = useCallback((config: BatchEvalConfig) => { + stoppingRef.current = false; + const initialSteps: Step[] = [ + { label: 'Starting batch evaluation...', status: 'running' }, + { label: 'Polling for results', status: 'pending' }, + { label: 'Fetching scores', status: 'pending' }, + ]; + setFlow({ name: 'running', config, steps: initialSteps, elapsed: 0 }); + }, []); + + // Execute batch evaluation + useEffect(() => { + if (flow.name !== 'running') return; + let cancelled = false; + + const { config } = flow; + const startTime = Date.now(); + + const timer = setInterval(() => { + if (!cancelled) { + setFlow(prev => { + if (prev.name !== 'running') return prev; + return { ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) }; + }); + } + }, 1000); + + void (async () => { + try { + const result = await runBatchEvaluationCommand({ + agent: config.agent, + evaluators: config.evaluators, + name: config.name || undefined, + sessionIds: config.sessionIds.length > 0 ? config.sessionIds : undefined, + lookbackDays: config.days, + sessionMetadata: config.sessionMetadata, + onProgress: (status, _message) => { + if (cancelled) return; + setFlow(prev => { + if (prev.name !== 'running') return prev; + const steps = [...prev.steps]; + if (status === 'running') { + steps[0] = { ...steps[0]!, status: 'success' }; + steps[1] = { ...steps[1]!, status: 'running' }; + } + return { ...prev, steps }; + }); + }, + onStarted: info => { + setFlow(prev => { + if (prev.name !== 'running') return prev; + return { ...prev, batchEvaluationId: info.batchEvaluationId, region: info.region }; + }); + }, + }); + + clearInterval(timer); + if (cancelled) return; + + // Save results locally + let savedFilePath: string | undefined; + if (result.success) { + try { + savedFilePath = saveBatchEvalRun(result); + } catch { + // Non-fatal + } + } + + if (!result.success) { + setFlow(prev => { + if (prev.name !== 'running') return prev; + const steps = prev.steps.map(s => + s.status === 'running' ? { ...s, status: 'error' as const, error: result.error } : s + ); + return { ...prev, steps }; + }); + await new Promise(resolve => setTimeout(resolve, 2000)); + if (cancelled) return; + setFlow({ + name: 'error', + message: result.error ?? 'Batch evaluation failed', + logFilePath: result.logFilePath, + }); + return; + } + + // Mark all steps success + setFlow(prev => { + if (prev.name !== 'running') return prev; + const steps = prev.steps.map(s => ({ ...s, status: 'success' as const })); + return { ...prev, steps }; + }); + + setFlow({ name: 'results', result, savedFilePath }); + } catch (err) { + clearInterval(timer); + if (!cancelled) { + const errorMsg = getErrorMessage(err); + setFlow(prev => { + if (prev.name !== 'running') return prev; + const steps = prev.steps.map(s => + s.status === 'running' ? { ...s, status: 'error' as const, error: errorMsg } : s + ); + return { ...prev, steps }; + }); + await new Promise(resolve => setTimeout(resolve, 2000)); + setFlow({ name: 'error', message: errorMsg }); + } + } + })(); + + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [flow.name]); // eslint-disable-line react-hooks/exhaustive-deps + + if (flow.name === 'loading') { + return ( + + + + ); + } + + if (flow.name === 'creds-error') { + return ; + } + + if (flow.name === 'wizard') { + return ( + + ); + } + + if (flow.name === 'running') { + const minutes = Math.floor(flow.elapsed / 60); + const seconds = flow.elapsed % 60; + const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; + + return ( + + + + + Agent: {flow.config.agent} + {' '} + Evaluators: {flow.config.evaluatorNames.join(', ')} + {' '} + ({timeStr}) + + + This may take a few minutes... + {flow.batchEvaluationId && Press Esc to stop the evaluation} + + + + ); + } + + if (flow.name === 'results') { + return ( + setFlow({ name: 'loading' })} + onExit={onExit} + /> + ); + } + + return ( + setFlow({ name: 'loading' })} + onExit={onExit} + /> + ); +} + +// ============================================================================ +// Wizard Component +// ============================================================================ + +interface BatchEvalWizardProps { + agents: AgentItem[]; + evaluators: EvaluatorItem[]; + onComplete: (config: BatchEvalConfig) => void; + onExit: () => void; +} + +function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit }: BatchEvalWizardProps) { + const skipAgent = agents.length <= 1; + const allSteps = useMemo( + () => + skipAgent + ? ['evaluators', 'days', 'sessions', 'ground-truth', 'name', 'confirm'] + : ['agent', 'evaluators', 'days', 'sessions', 'ground-truth', 'name', 'confirm'], + [skipAgent] + ); + + const [step, setStep] = useState(allSteps[0]!); + const [config, setConfig] = useState({ + agent: skipAgent ? agents[0]!.name : '', + evaluators: [], + evaluatorNames: [], + days: DEFAULT_LOOKBACK_DAYS, + sessionIds: [], + groundTruthFile: '', + sessionMetadata: undefined, + name: '', + }); + + const currentIndex = allSteps.indexOf(step); + const [groundTruthError, setGroundTruthError] = useState(null); + const [gtMode, setGtMode] = useState<'choose' | 'file' | 'inline'>('choose'); + + const goBack = useCallback(() => { + const prev = allSteps[currentIndex - 1]; + if (prev) { + if (prev === 'ground-truth') setGtMode('choose'); + setStep(prev); + } else onExit(); + }, [allSteps, currentIndex, onExit]); + + const goNext = useCallback(() => { + const next = allSteps[currentIndex + 1]; + if (next) setStep(next); + }, [allSteps, currentIndex]); + + const agentItems: SelectableItem[] = useMemo( + () => agents.map(a => ({ id: a.name, title: a.name, description: a.build })), + [agents] + ); + + const evaluatorItems: SelectableItem[] = useMemo( + () => + rawEvaluators.map(e => ({ + id: e.arn, + title: e.name, + description: e.type === 'Builtin' ? 'Built-in evaluator' : (e.description ?? 'Custom evaluator'), + })), + [rawEvaluators] + ); + + // ── Session discovery ────────────────────────────────────────────────────── + + type SessionResult = { phase: 'loaded'; sessions: SessionInfo[] } | { phase: 'error'; message: string }; + + const [sessionResult, setSessionResult] = useState(); + const fetchingRef = useRef(''); + + const isAgentStep = step === 'agent'; + const isEvaluatorsStep = step === 'evaluators'; + const isDaysStep = step === 'days'; + const isSessionsStep = step === 'sessions'; + const isGroundTruthStep = step === 'ground-truth'; + const isNameStep = step === 'name'; + const isConfirmStep = step === 'confirm'; + + const fetchKey = `${config.agent}:${config.days}`; + const sessionPhase = !isSessionsStep ? 'idle' : sessionResult?.key === fetchKey ? sessionResult.phase : 'loading'; + + useEffect(() => { + if (!isSessionsStep) return; + if (sessionResult?.key === fetchKey) return; + if (fetchingRef.current === fetchKey) return; + fetchingRef.current = fetchKey; + let cancelled = false; + + void (async () => { + try { + const context = await loadDeployedProjectConfig(); + const targetRegion = context.awsTargets?.[0]?.region; + const { region: detectedRegion } = await detectRegion(); + const region = targetRegion ?? detectedRegion; + const agentResult = resolveAgent(context, { runtime: config.agent }); + if (!agentResult.success) { + if (!cancelled) setSessionResult({ key: fetchKey, phase: 'error', message: agentResult.error }); + return; + } + + const sessions = await discoverSessions({ + runtimeId: agentResult.agent.runtimeId, + region, + lookbackDays: config.days, + }); + + if (cancelled) return; + + if (sessions.length === 0) { + setSessionResult({ + key: fetchKey, + phase: 'error', + message: 'No sessions found in the lookback window. Try increasing the lookback days.', + }); + } else { + setSessionResult({ key: fetchKey, phase: 'loaded', sessions }); + } + } catch (err) { + if (!cancelled) { + setSessionResult({ + key: fetchKey, + phase: 'error', + message: err instanceof Error ? err.message : 'Failed to discover sessions', + }); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [isSessionsStep, fetchKey]); // eslint-disable-line react-hooks/exhaustive-deps + + const sessionItems: SelectableItem[] = useMemo(() => { + const sessions = sessionResult?.phase === 'loaded' ? sessionResult.sessions : []; + return sessions.map(s => { + const date = s.firstSeen + ? new Date(s.firstSeen).toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : ''; + const shortId = s.sessionId.length > 36 ? s.sessionId.slice(0, 36) + '…' : s.sessionId; + return { + id: s.sessionId, + title: shortId, + description: `${s.spanCount} spans · ${date}`, + }; + }); + }, [sessionResult]); + + // ── Navigation hooks ────────────────────────────────────────────────────── + + const agentNav = useListNavigation({ + items: agentItems, + onSelect: item => { + setConfig(c => ({ ...c, agent: item.id })); + goNext(); + }, + onExit, + isActive: isAgentStep, + }); + + const evaluatorsNav = useMultiSelectNavigation({ + items: evaluatorItems, + getId: item => item.id, + onConfirm: ids => { + const names = ids.map(id => { + const item = rawEvaluators.find(e => e.arn === id); + return item?.name ?? id; + }); + setConfig(c => ({ ...c, evaluators: ids, evaluatorNames: names })); + goNext(); + }, + onExit: () => goBack(), + isActive: isEvaluatorsStep, + requireSelection: true, + }); + + // Handle Esc during session loading/error + useListNavigation({ + items: [{ id: 'back', title: 'Back' }], + onSelect: () => goBack(), + onExit: () => goBack(), + isActive: isSessionsStep && sessionPhase !== 'loaded', + }); + + const sessionsNav = useMultiSelectNavigation({ + items: sessionItems, + getId: item => item.id, + onConfirm: ids => { + setConfig(c => ({ ...c, sessionIds: ids })); + goNext(); + }, + onExit: () => goBack(), + isActive: isSessionsStep && sessionPhase === 'loaded', + requireSelection: true, + }); + + const gtChoiceItems: SelectableItem[] = useMemo( + () => [ + { id: 'skip', title: 'Skip', description: 'No ground truth' }, + { id: 'file', title: 'Load from file', description: 'JSON file with session metadata and ground truth' }, + { id: 'inline', title: 'Enter manually', description: 'Type assertions, trajectory, and expected response' }, + ], + [] + ); + + const gtChoiceNav = useListNavigation({ + items: gtChoiceItems, + onSelect: item => { + setGroundTruthError(null); + if (item.id === 'skip') { + setConfig(c => ({ ...c, groundTruthFile: '', sessionMetadata: undefined })); + goNext(); + } else if (item.id === 'file') { + setGtMode('file'); + } else { + setGtMode('inline'); + } + }, + onExit: () => goBack(), + isActive: isGroundTruthStep && gtMode === 'choose', + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(config), + onExit: () => goBack(), + isActive: isConfirmStep, + }); + + const helpText = isAgentStep + ? HELP_TEXT.NAVIGATE_SELECT + : isEvaluatorsStep + ? 'Space toggle · Enter confirm · Esc back' + : isDaysStep + ? HELP_TEXT.TEXT_INPUT + : isSessionsStep + ? sessionPhase === 'loading' + ? '' + : sessionPhase === 'error' + ? HELP_TEXT.CONFIRM_CANCEL + : 'Space toggle · Enter confirm · Esc back' + : isGroundTruthStep + ? gtMode === 'choose' + ? HELP_TEXT.NAVIGATE_SELECT + : gtMode === 'file' + ? HELP_TEXT.TEXT_INPUT + : 'Enter value · Enter on empty to skip section · Esc back' + : isNameStep + ? HELP_TEXT.TEXT_INPUT + : HELP_TEXT.CONFIRM_CANCEL; + + const headerContent = ; + + return ( + + + {isAgentStep && ( + + )} + + {isEvaluatorsStep && ( + + )} + + {isDaysStep && ( + + Note: Traces may take 5–10 min to appear after agent invocations. + { + const days = parseInt(value, 10); + if (!isNaN(days) && days >= 1 && days <= 90) { + setConfig(c => ({ ...c, days })); + goNext(); + } + }} + onCancel={() => goBack()} + customValidation={value => { + const days = parseInt(value, 10); + if (isNaN(days)) return 'Must be a number'; + if (days < 1 || days > 90) return 'Must be between 1 and 90'; + return true; + }} + /> + + )} + + {isSessionsStep && sessionPhase === 'loading' && } + + {isSessionsStep && sessionResult?.phase === 'error' && {sessionResult.message}} + + {isSessionsStep && sessionPhase === 'loaded' && ( + + )} + + {isGroundTruthStep && gtMode === 'choose' && ( + + )} + + {isGroundTruthStep && gtMode === 'file' && ( + + Select a JSON file with session ground truth (assertions, expected trajectory, turns). + {groundTruthError && {groundTruthError}} + { + setGroundTruthError(null); + try { + const resolved = resolvePath(value.trim()); + const content = readFileSync(resolved, 'utf-8'); + const parsed = JSON.parse(content) as Record; + const metadata: SessionMetadataEntry[] = Array.isArray(parsed) + ? (parsed as SessionMetadataEntry[]) + : (parsed.sessionMetadata as SessionMetadataEntry[]); + if (!Array.isArray(metadata)) { + setGroundTruthError('File must be a JSON array or contain a "sessionMetadata" array'); + return; + } + setConfig(c => ({ ...c, groundTruthFile: resolved, sessionMetadata: metadata })); + goNext(); + } catch (err) { + setGroundTruthError(`Failed to load file: ${err instanceof Error ? err.message : String(err)}`); + } + }} + onCancel={() => { + setGroundTruthError(null); + setGtMode('choose'); + }} + /> + + )} + + {isGroundTruthStep && gtMode === 'inline' && ( + { + // Apply the same ground truth to all selected sessions + const metadata: SessionMetadataEntry[] = config.sessionIds.map(sid => ({ + sessionId: sid, + groundTruth: { + inline: { + ...(gt.assertions.length > 0 ? { assertions: gt.assertions.map(text => ({ text })) } : {}), + ...(gt.expectedTrajectory.length > 0 + ? { expectedTrajectory: { toolNames: gt.expectedTrajectory } } + : {}), + ...(gt.expectedResponse + ? { + turns: [ + { + input: { prompt: '' }, + expectedResponse: { text: gt.expectedResponse }, + }, + ], + } + : {}), + }, + }, + })); + setConfig(c => ({ ...c, groundTruthFile: '', sessionMetadata: metadata })); + goNext(); + }} + onCancel={() => { + setGtMode('choose'); + }} + /> + )} + + {isNameStep && ( + + Optional — leave blank for auto-generated name. + { + setConfig(c => ({ ...c, name: value })); + goNext(); + }} + onCancel={() => goBack()} + /> + + )} + + {isConfirmStep && ( + + )} + + + ); +} + +// ============================================================================ +// Results View +// ============================================================================ + +function scoreColor(score: number): string { + if (score >= 0.8) return 'green'; + if (score >= 0.5) return 'yellow'; + return 'red'; +} + +interface ResultsViewProps { + result: RunBatchEvaluationCommandResult; + savedFilePath?: string; + onRunAnother: () => void; + onExit: () => void; +} + +function ResultsView({ result, savedFilePath, onRunAnother, onExit }: ResultsViewProps) { + const actions = [ + { id: 'another', title: 'Run another batch evaluation' }, + { id: 'back', title: 'Back' }, + ]; + + const nav = useListNavigation({ + items: actions, + onSelect: item => { + if (item.id === 'another') onRunAnother(); + else onExit(); + }, + onExit, + isActive: true, + }); + + const evalRes = result.evaluationResults; + const summaries = evalRes?.evaluatorSummaries; + + // Fall back to local grouping when API summaries aren't available + const byEvaluator = useMemo(() => { + if (summaries && summaries.length > 0) return null; + const map = new Map(); + for (const r of result.results) { + const group = map.get(r.evaluatorId) ?? []; + group.push(r); + map.set(r.evaluatorId, group); + } + return map; + }, [result.results, summaries]); + + return ( + + + + ✓ Batch evaluation complete + + ID: {result.batchEvaluationId} + {' '} + Status: {result.status} + + {result.name && ( + + Name: {result.name} + + )} + + {evalRes?.totalNumberOfSessions != null && ( + + Sessions: {evalRes.totalNumberOfSessions} total + {evalRes.numberOfSessionsCompleted != null && ( + , {evalRes.numberOfSessionsCompleted} completed + )} + {evalRes.numberOfSessionsFailed ? ( + , {evalRes.numberOfSessionsFailed} failed + ) : null} + + )} + + {summaries && summaries.length > 0 ? ( + + Scores range from 0 (worst) to 1 (best). + {summaries.map(s => { + const avg = s.statistics?.averageScore; + const avgStr = avg != null ? avg.toFixed(2) : 'N/A'; + const color = avg != null ? scoreColor(avg) : undefined; + return ( + + {' '} + {s.evaluatorId} + {' '} + {avgStr} + {s.totalFailed ? ({s.totalFailed} failed) : null} + {s.totalEvaluated != null && [{s.totalEvaluated} evaluated]} + + ); + })} + + ) : byEvaluator && byEvaluator.size > 0 ? ( + + Scores range from 0 (worst) to 1 (best). + {[...byEvaluator.entries()].map(([evalId, evalResults]) => { + const scores = evalResults.filter(r => !r.error).map(r => r.score!); + const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0; + const errors = evalResults.filter(r => r.error).length; + return ( + + {' '} + {evalId} + {' '} + {avg.toFixed(2)} + {errors > 0 && ({errors} errors)} + + ); + })} + + ) : ( + + No evaluation results returned. + + )} + + {savedFilePath && ( + + Results saved to: {savedFilePath} + + )} + {result.logFilePath && ( + + Log: {result.logFilePath} + + )} + + + {actions.map((action, idx) => { + const selected = idx === nav.selectedIndex; + return ( + + {selected ? '❯' : ' '} + + {action.title} + + + ); + })} + + + + + ); +} diff --git a/src/cli/tui/screens/run-eval/RunScreen.tsx b/src/cli/tui/screens/run-eval/RunScreen.tsx index 6675983f9..a9a797a37 100644 --- a/src/cli/tui/screens/run-eval/RunScreen.tsx +++ b/src/cli/tui/screens/run-eval/RunScreen.tsx @@ -6,10 +6,12 @@ import React, { useMemo } from 'react'; interface RunScreenProps { onRunEval: () => void; + onRunBatchEval: () => void; + onRunRecommendation: () => void; onExit: () => void; } -export function RunScreen({ onRunEval, onExit }: RunScreenProps) { +export function RunScreen({ onRunEval, onRunBatchEval, onRunRecommendation, onExit }: RunScreenProps) { const items: SelectableItem[] = useMemo( () => [ { @@ -17,13 +19,27 @@ export function RunScreen({ onRunEval, onExit }: RunScreenProps) { title: 'On-demand Evaluation', description: 'Evaluate agent traces with selected evaluators. CLI also supports --agent-arn.', }, + { + id: 'run-batch-eval', + title: 'Batch Evaluation', + description: 'Run a batch evaluation against agent sessions via CloudWatch.', + }, + { + id: 'run-recommendation', + title: 'Recommendation', + description: 'Optimize system prompts or tool descriptions using agent traces.', + }, ], [] ); const nav = useListNavigation({ items, - onSelect: () => onRunEval(), + onSelect: item => { + if (item.id === 'run-eval') onRunEval(); + else if (item.id === 'run-batch-eval') onRunBatchEval(); + else if (item.id === 'run-recommendation') onRunRecommendation(); + }, onExit, isActive: true, }); diff --git a/src/cli/tui/screens/run-eval/index.ts b/src/cli/tui/screens/run-eval/index.ts index d76e0e086..7c56bd639 100644 --- a/src/cli/tui/screens/run-eval/index.ts +++ b/src/cli/tui/screens/run-eval/index.ts @@ -1,3 +1,5 @@ +export { BatchEvalHistoryScreen } from './BatchEvalHistoryScreen'; +export { RunBatchEvalFlow } from './RunBatchEvalFlow'; export { RunEvalFlow } from './RunEvalFlow'; export { RunEvalScreen } from './RunEvalScreen'; export { RunScreen } from './RunScreen'; diff --git a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx index 2404109fc..83bda78e5 100644 --- a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx +++ b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx @@ -1,5 +1,6 @@ import { ConfigIO } from '../../../../lib'; import { runtimeEndpointPrimitive } from '../../../primitives/registry'; +import { withAddTelemetry } from '../../../telemetry/cli-command-run.js'; import { ErrorPrompt } from '../../components'; import { AddSuccessScreen } from '../add/AddSuccessScreen'; import { AddRuntimeEndpointScreen } from './AddRuntimeEndpointScreen'; @@ -78,24 +79,24 @@ export function AddRuntimeEndpointFlow({ }, [isInteractive, flow.name, onExit]); const handleCreateComplete = useCallback((config: RuntimeEndpointWizardConfig) => { - void runtimeEndpointPrimitive - .add({ + void withAddTelemetry('add.runtime-endpoint', {}, () => + runtimeEndpointPrimitive.add({ runtime: config.runtimeName, endpoint: config.endpointName, version: config.version, description: config.description, }) - .then(result => { - if (result.success) { - setFlow({ - name: 'create-success', - endpointName: config.endpointName, - runtimeName: config.runtimeName, - }); - return; - } - setFlow({ name: 'error', message: result.error ?? 'Unknown error' }); - }); + ).then(result => { + if (result.success) { + setFlow({ + name: 'create-success', + endpointName: config.endpointName, + runtimeName: config.runtimeName, + }); + return; + } + setFlow({ name: 'error', message: result.error ?? 'Unknown error' }); + }); }, []); if (flow.name === 'loading') { diff --git a/src/cli/tui/utils/commands.ts b/src/cli/tui/utils/commands.ts index 1ea68e3b5..8014e5d98 100644 --- a/src/cli/tui/utils/commands.ts +++ b/src/cli/tui/utils/commands.ts @@ -12,12 +12,12 @@ export interface CommandMeta { /** * Commands hidden from TUI entirely (meta commands). */ -const HIDDEN_FROM_TUI = ['help', 'telemetry'] as const; +const HIDDEN_FROM_TUI = ['help', 'telemetry', 'promote'] as const; /** * Commands that are CLI-only (shown but marked as requiring CLI invocation). */ -const CLI_ONLY_COMMANDS = ['logs', 'traces', 'pause', 'resume'] as const; +const CLI_ONLY_COMMANDS = ['logs', 'traces', 'pause', 'resume', 'stop', 'archive'] as const; /** * Commands hidden from TUI when inside an existing project. diff --git a/src/lib/packaging/build-args.ts b/src/lib/packaging/build-args.ts index e5af34045..eadc35875 100644 --- a/src/lib/packaging/build-args.ts +++ b/src/lib/packaging/build-args.ts @@ -1,11 +1,11 @@ -import { readCliConfig } from '../schemas/io/cli-config'; +import { readGlobalConfigSync } from '../schemas/io/global-config'; /** * Return Docker --build-arg flags for UV index URLs configured in ~/.agentcore/config.json. * Returns an empty array when no custom indexes are configured. */ export function getUvBuildArgs(): string[] { - const config = readCliConfig(); + const config = readGlobalConfigSync(); const args: string[] = []; if (config.uvDefaultIndex) args.push('--build-arg', `UV_DEFAULT_INDEX=${config.uvDefaultIndex}`); if (config.uvIndex) args.push('--build-arg', `UV_INDEX=${config.uvIndex}`); diff --git a/src/lib/schemas/io/cli-config.ts b/src/lib/schemas/io/cli-config.ts deleted file mode 100644 index aa36d82f1..000000000 --- a/src/lib/schemas/io/cli-config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { readFileSync } from 'fs'; -import { homedir } from 'os'; -import { join } from 'path'; - -const CONFIG_FILE = join(homedir(), '.agentcore', 'config.json'); - -export interface CliConfig { - uvDefaultIndex?: string; - uvIndex?: string; - disableTransactionSearch?: boolean; - transactionSearchIndexPercentage?: number; -} - -/** - * Read the global CLI config from ~/.agentcore/config.json. - * Returns an empty object if the file doesn't exist or is malformed. - */ -export function readCliConfig(): CliConfig { - try { - const data = readFileSync(CONFIG_FILE, 'utf-8'); - const parsed: Record = JSON.parse(data) as Record; - const config: CliConfig = {}; - if (typeof parsed.uvDefaultIndex === 'string') config.uvDefaultIndex = parsed.uvDefaultIndex; - if (typeof parsed.uvIndex === 'string') config.uvIndex = parsed.uvIndex; - if (parsed.disableTransactionSearch === true) config.disableTransactionSearch = true; - if (typeof parsed.transactionSearchIndexPercentage === 'number') { - const pct = parsed.transactionSearchIndexPercentage; - if (pct >= 0 && pct <= 100) { - config.transactionSearchIndexPercentage = pct; - } - } - return config; - } catch { - return {}; - } -} diff --git a/src/lib/schemas/io/config-io.ts b/src/lib/schemas/io/config-io.ts index 423403f90..a62b48e75 100644 --- a/src/lib/schemas/io/config-io.ts +++ b/src/lib/schemas/io/config-io.ts @@ -110,7 +110,12 @@ export class ConfigIO { */ async writeProjectSpec(data: AgentCoreProjectSpec): Promise { const filePath = this.pathResolver.getAgentConfigPath(); - await this.validateAndWrite(filePath, 'AgentCore Project Config', AgentCoreProjectSpecSchema, data); + // TODO: extend this to all resource arrays so empty defaults never pollute agentcore.json + const cleaned = { ...data }; + if (cleaned.configBundles?.length === 0) delete (cleaned as Record).configBundles; + if (cleaned.abTests?.length === 0) delete (cleaned as Record).abTests; + if (cleaned.httpGateways?.length === 0) delete (cleaned as Record).httpGateways; + await this.validateAndWrite(filePath, 'AgentCore Project Config', AgentCoreProjectSpecSchema, cleaned); } /** diff --git a/src/cli/global-config.ts b/src/lib/schemas/io/global-config.ts similarity index 73% rename from src/cli/global-config.ts rename to src/lib/schemas/io/global-config.ts index 267ad6669..fc64eb39b 100644 --- a/src/cli/global-config.ts +++ b/src/lib/schemas/io/global-config.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'fs'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { randomUUID } from 'node:crypto'; import { homedir } from 'os'; @@ -9,18 +10,19 @@ export const GLOBAL_CONFIG_FILE = join(GLOBAL_CONFIG_DIR, 'config.json'); const GlobalConfigSchema = z .object({ - installationId: z.string().optional(), - uvDefaultIndex: z.string().optional(), - uvIndex: z.string().optional(), - disableTransactionSearch: z.boolean().optional(), - transactionSearchIndexPercentage: z.number().min(0).max(100).optional(), + installationId: z.string().optional().catch(undefined), + uvDefaultIndex: z.string().optional().catch(undefined), + uvIndex: z.string().optional().catch(undefined), + disableTransactionSearch: z.boolean().optional().catch(undefined), + transactionSearchIndexPercentage: z.number().int().min(0).max(100).optional().catch(undefined), telemetry: z .object({ - enabled: z.boolean().optional(), - endpoint: z.string().optional(), - audit: z.boolean().optional(), + enabled: z.boolean().optional().catch(undefined), + endpoint: z.string().optional().catch(undefined), + audit: z.boolean().optional().catch(undefined), }) - .optional(), + .optional() + .catch(undefined), }) .passthrough(); @@ -35,6 +37,15 @@ export async function readGlobalConfig(configFile = GLOBAL_CONFIG_FILE): Promise } } +export function readGlobalConfigSync(configFile = GLOBAL_CONFIG_FILE): GlobalConfig { + try { + const data = readFileSync(configFile, 'utf-8'); + return GlobalConfigSchema.parse(JSON.parse(data)); + } catch { + return {}; + } +} + export async function updateGlobalConfig( partial: GlobalConfig, configDir = GLOBAL_CONFIG_DIR, diff --git a/src/lib/schemas/io/index.ts b/src/lib/schemas/io/index.ts index e8ddddc0f..212468ffe 100644 --- a/src/lib/schemas/io/index.ts +++ b/src/lib/schemas/io/index.ts @@ -11,4 +11,3 @@ export { type PathConfig, } from './path-resolver'; export { ConfigIO, createConfigIO, getSchemaUrlForVersion } from './config-io'; -export { readCliConfig, type CliConfig } from './cli-config'; diff --git a/src/schema/__tests__/constants.test.ts b/src/schema/__tests__/constants.test.ts index 546770b6b..2d8bd3bc9 100644 --- a/src/schema/__tests__/constants.test.ts +++ b/src/schema/__tests__/constants.test.ts @@ -39,72 +39,45 @@ describe('matchEnumValue', () => { }); describe('SDKFrameworkSchema', () => { - it.each(['Strands', 'LangChain_LangGraph', 'GoogleADK', 'OpenAIAgents'])('accepts "%s"', framework => { - expect(SDKFrameworkSchema.safeParse(framework).success).toBe(true); - }); - - it('rejects invalid framework', () => { + it('accepts valid frameworks and rejects invalid', () => { + expect(SDKFrameworkSchema.safeParse('Strands').success).toBe(true); + expect(SDKFrameworkSchema.safeParse('OpenAIAgents').success).toBe(true); expect(SDKFrameworkSchema.safeParse('AutoGen').success).toBe(false); expect(SDKFrameworkSchema.safeParse('strands').success).toBe(false); // case-sensitive }); }); describe('ModelProviderSchema', () => { - it.each(['Bedrock', 'Gemini', 'OpenAI', 'Anthropic'])('accepts "%s"', provider => { - expect(ModelProviderSchema.safeParse(provider).success).toBe(true); - }); - - it('rejects invalid provider', () => { + it('accepts valid providers and rejects invalid', () => { + expect(ModelProviderSchema.safeParse('Bedrock').success).toBe(true); + expect(ModelProviderSchema.safeParse('Anthropic').success).toBe(true); expect(ModelProviderSchema.safeParse('Azure').success).toBe(false); }); }); -describe('PythonRuntimeSchema', () => { - it.each(['PYTHON_3_10', 'PYTHON_3_11', 'PYTHON_3_12', 'PYTHON_3_13', 'PYTHON_3_14'])('accepts "%s"', version => { - expect(PythonRuntimeSchema.safeParse(version).success).toBe(true); +describe('RuntimeVersionSchemas', () => { + it('accepts valid Python and Node versions', () => { + expect(PythonRuntimeSchema.safeParse('PYTHON_3_10').success).toBe(true); + expect(PythonRuntimeSchema.safeParse('PYTHON_3_14').success).toBe(true); + expect(NodeRuntimeSchema.safeParse('NODE_18').success).toBe(true); + expect(NodeRuntimeSchema.safeParse('NODE_22').success).toBe(true); + expect(RuntimeVersionSchema.safeParse('PYTHON_3_12').success).toBe(true); + expect(RuntimeVersionSchema.safeParse('NODE_20').success).toBe(true); }); - it('rejects unsupported versions', () => { + it('rejects invalid versions', () => { expect(PythonRuntimeSchema.safeParse('PYTHON_3_9').success).toBe(false); expect(PythonRuntimeSchema.safeParse('PYTHON_3_15').success).toBe(false); - }); -}); - -describe('NodeRuntimeSchema', () => { - it.each(['NODE_18', 'NODE_20', 'NODE_22'])('accepts "%s"', version => { - expect(NodeRuntimeSchema.safeParse(version).success).toBe(true); - }); - - it('rejects unsupported versions', () => { expect(NodeRuntimeSchema.safeParse('NODE_16').success).toBe(false); expect(NodeRuntimeSchema.safeParse('NODE_24').success).toBe(false); - }); -}); - -describe('RuntimeVersionSchema', () => { - it('accepts Python versions', () => { - expect(RuntimeVersionSchema.safeParse('PYTHON_3_12').success).toBe(true); - }); - - it('accepts Node versions', () => { - expect(RuntimeVersionSchema.safeParse('NODE_20').success).toBe(true); - }); - - it('rejects invalid versions', () => { expect(RuntimeVersionSchema.safeParse('RUBY_3_0').success).toBe(false); }); }); describe('NetworkModeSchema', () => { - it('accepts PUBLIC', () => { + it('accepts valid modes and rejects invalid', () => { expect(NetworkModeSchema.safeParse('PUBLIC').success).toBe(true); - }); - - it('accepts VPC', () => { expect(NetworkModeSchema.safeParse('VPC').success).toBe(true); - }); - - it('rejects other modes', () => { expect(NetworkModeSchema.safeParse('PRIVATE').success).toBe(false); }); }); diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index 5819cbd47..c86c14cb7 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -18,6 +18,16 @@ interface AgentCoreProjectSpec { runtimes: AgentEnvSpec[]; // Unique by name memories: Memory[]; // Unique by name credentials: Credential[]; // Unique by name + evaluators: Evaluator[]; // Unique by name — custom evaluator definitions + onlineEvalConfigs: OnlineEvalConfig[]; // Unique by name — online evaluation configs + agentCoreGateways: AgentCoreGateway[]; // Unique by name — MCP gateways + mcpRuntimeTools?: AgentCoreMcpRuntimeTool[]; // Unique by name — standalone MCP runtime tools (not behind a gateway) + unassignedTargets?: AgentCoreGatewayTarget[]; // Unique by name — targets not yet assigned to a gateway + policyEngines: PolicyEngine[]; // Unique by name — Cedar policy engines + configBundles: ConfigBundle[]; // Unique by name — configuration bundles for versioned config + abTests: ABTest[]; // Unique by name — A/B test experiments + /** @internal Auto-managed by AB test creation. Do not configure directly. */ + httpGateways: HttpGateway[]; // Unique by name — HTTP gateways bound to a runtime } // ───────────────────────────────────────────────────────────────────────────── @@ -36,6 +46,15 @@ interface NetworkConfig { type MemoryStrategyType = 'SEMANTIC' | 'SUMMARIZATION' | 'USER_PREFERENCE' | 'EPISODIC'; type ModelProvider = 'Bedrock' | 'Gemini' | 'OpenAI' | 'Anthropic'; +type EvaluationLevel = 'SESSION' | 'TRACE' | 'TOOL_CALL'; +type GatewayTargetType = 'lambda' | 'mcpServer' | 'openApiSchema' | 'smithyModel' | 'apiGateway' | 'lambdaFunctionArn'; +type OutboundAuthType = 'OAUTH' | 'API_KEY' | 'NONE'; +type GatewayAuthorizerType = 'NONE' | 'AWS_IAM' | 'CUSTOM_JWT'; +type GatewayExceptionLevel = 'NONE' | 'DEBUG'; +type PolicyEngineMode = 'LOG_ONLY' | 'ENFORCE'; +type ValidationMode = 'FAIL_ON_ANY_FINDINGS' | 'IGNORE_ALL_FINDINGS'; +type ComputeHost = 'Lambda' | 'AgentCoreRuntime'; +type ABTestVariantName = 'C' | 'T1'; // ───────────────────────────────────────────────────────────────────────────── // AGENT @@ -74,8 +93,10 @@ interface EnvVar { interface Memory { name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ @max 48 eventExpiryDuration: number; // @min 3 @max 365 (days) - strategies: MemoryStrategy[]; // @min 1, unique by type + strategies: MemoryStrategy[]; // Unique by type. Can be empty (short-term memory). tags?: Record; + encryptionKeyArn?: string; + executionRoleArn?: string; } interface MemoryStrategy { @@ -93,4 +114,290 @@ interface MemoryStrategy { interface Credential { authorizerType: 'ApiKeyCredentialProvider' | 'OAuthCredentialProvider'; name: string; // @regex ^[a-zA-Z0-9\-_]+$ @min 1 @max 128 + // Additional fields for OAuthCredentialProvider: + discoveryUrl?: string; // OIDC discovery URL (OAuth only) + scopes?: string[]; // Supported scopes (OAuth only) + vendor?: string; // Credential provider vendor type (OAuth only, default: 'CustomOauth2') + managed?: boolean; // Whether auto-created by CLI (OAuth only) + usage?: 'inbound' | 'outbound'; // Auth direction (OAuth only) +} + +// ───────────────────────────────────────────────────────────────────────────── +// EVALUATOR +// ───────────────────────────────────────────────────────────────────────────── + +interface Evaluator { + name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ @max 48 + level: EvaluationLevel; + description?: string; + config: EvaluatorConfig; // Must have either llmAsAJudge or codeBased, not both + tags?: Record; +} + +interface EvaluatorConfig { + llmAsAJudge?: LlmAsAJudgeConfig; + codeBased?: CodeBasedConfig; +} + +interface LlmAsAJudgeConfig { + model: string; // Bedrock model ID or ARN + instructions: string; // Evaluation instructions + ratingScale: RatingScale; // Must have either numerical or categorical, not both +} + +interface RatingScale { + numerical?: { value: number; label: string; definition: string }[]; + categorical?: { label: string; definition: string }[]; +} + +interface CodeBasedConfig { + managed?: ManagedCodeBasedConfig; + external?: ExternalCodeBasedConfig; +} + +interface ManagedCodeBasedConfig { + codeLocation: string; + entrypoint: string; // default 'lambda_function.handler' + timeoutSeconds: number; // @min 1 @max 300 (default 60) + additionalPolicies?: string[]; +} + +interface ExternalCodeBasedConfig { + lambdaArn: string; // @regex ^arn:aws[a-z-]*:lambda:[a-z0-9-]+:\d{12}:function:.+$ +} + +// ───────────────────────────────────────────────────────────────────────────── +// ONLINE EVAL CONFIG +// ───────────────────────────────────────────────────────────────────────────── + +interface OnlineEvalConfig { + name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ @max 48 + agent: string; // Agent name — must match a project agent + evaluators: string[]; // @min 1 — evaluator names, Builtin.* IDs, or evaluator ARNs + samplingRate: number; // @min 0.01 @max 100 (percentage) + description?: string; // @max 200 + enableOnCreate?: boolean; // Whether to enable on create (default: true) + tags?: Record; +} + +// ───────────────────────────────────────────────────────────────────────────── +// GATEWAY (MCP) +// ───────────────────────────────────────────────────────────────────────────── + +interface AgentCoreGateway { + name: string; // @regex ^[0-9a-zA-Z](?:[0-9a-zA-Z-]*[0-9a-zA-Z])?$ @max 100 + description?: string; + targets: AgentCoreGatewayTarget[]; // Gateway targets + authorizerType?: GatewayAuthorizerType; // default 'NONE' + authorizerConfiguration?: AuthorizerConfig; // Required when authorizerType is 'CUSTOM_JWT' + enableSemanticSearch?: boolean; // default true + exceptionLevel?: GatewayExceptionLevel; // default 'NONE' + policyEngineConfiguration?: GatewayPolicyEngineConfiguration; + tags?: Record; +} + +interface AuthorizerConfig { + customJwtAuthorizer?: { + discoveryUrl: string; // OIDC discovery URL (HTTPS, must end with /.well-known/openid-configuration) + allowedAudience?: string[]; + allowedClients?: string[]; + allowedScopes?: string[]; + customClaims?: CustomClaimValidation[]; + }; +} + +interface CustomClaimValidation { + inboundTokenClaimName: string; // @regex ^[A-Za-z0-9_.:-]+$ @max 255 + inboundTokenClaimValueType: 'STRING' | 'STRING_ARRAY'; + authorizingClaimMatchValue: { + claimMatchOperator: 'EQUALS' | 'CONTAINS' | 'CONTAINS_ANY'; + claimMatchValue: { + matchValueString?: string; // @regex ^[A-Za-z0-9_.-]+$ @max 255 + matchValueStringList?: string[]; // each @regex ^[A-Za-z0-9_.-]+$ @max 255 + }; + }; +} + +interface GatewayPolicyEngineConfiguration { + policyEngineName: string; // Reference to a PolicyEngine name + mode: PolicyEngineMode; +} + +// ───────────────────────────────────────────────────────────────────────────── +// GATEWAY TARGET +// ───────────────────────────────────────────────────────────────────────────── + +interface AgentCoreGatewayTarget { + name: string; + targetType: GatewayTargetType; + toolDefinitions?: ToolDefinition[]; // Required for 'lambda' targets + compute?: ToolComputeConfig; // Required for 'lambda' and scaffold targets + endpoint?: string; // URL — required for external 'mcpServer' targets + outboundAuth?: OutboundAuth; + apiGateway?: ApiGatewayConfig; // Required for 'apiGateway' target type + schemaSource?: SchemaSource; // Required for 'openApiSchema' / 'smithyModel' targets + lambdaFunctionArn?: LambdaFunctionArnConfig; // Required for 'lambdaFunctionArn' target type +} + +interface OutboundAuth { + type: OutboundAuthType; // default 'NONE' + credentialName?: string; // Required when type is not 'NONE' + scopes?: string[]; +} + +interface ToolDefinition { + name: string; + description?: string; + inputSchema: object; // JSON Schema + outputSchema?: object; +} + +interface ToolComputeConfig { + host: ComputeHost; + implementation: ToolImplementationBinding; + // Lambda-specific: + nodeVersion?: NodeRuntime; // Required for TypeScript Lambda + pythonVersion?: PythonRuntime; // Required for Python Lambda + timeout?: number; // @min 1 @max 900 + memorySize?: number; // @min 128 @max 10240 + iamPolicy?: object; // IAM policy document + // AgentCoreRuntime-specific: + runtime?: RuntimeConfig; +} + +interface ToolImplementationBinding { + language: 'TypeScript' | 'Python'; + path: string; + handler: string; +} + +interface RuntimeConfig { + artifact: 'CodeZip'; + pythonVersion: PythonRuntime; + name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ @max 48 + entrypoint: string; // Python file path with optional handler + codeLocation: string; + instrumentation?: Instrumentation; + networkMode?: NetworkMode; // default 'PUBLIC' + description?: string; +} + +interface ApiGatewayConfig { + restApiId: string; + stage: string; + apiGatewayToolConfiguration: { + toolFilters: { + filterPath: string; + methods: ('GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS')[]; + }[]; + toolOverrides?: { name: string; path: string; method: string; description?: string }[]; + }; +} + +interface LambdaFunctionArnConfig { + lambdaArn: string; // @max 170 + toolSchemaFile: string; +} + +type SchemaSource = { inline: { path: string } } | { s3: { uri: string; bucketOwnerAccountId?: string } }; + +// ───────────────────────────────────────────────────────────────────────────── +// MCP RUNTIME TOOL +// ───────────────────────────────────────────────────────────────────────────── + +interface AgentCoreMcpRuntimeTool { + name: string; + toolDefinition: ToolDefinition; + compute: { + host: 'AgentCoreRuntime'; // Only AgentCoreRuntime (Python only) + implementation: ToolImplementationBinding; + runtime?: RuntimeConfig; + iamPolicy?: object; + }; + bindings?: McpRuntimeBinding[]; // Grant agents permission to invoke this tool +} + +interface McpRuntimeBinding { + runtimeName: string; // Agent runtime name to bind to + envVarName: string; // @regex ^[A-Za-z_][A-Za-z0-9_]*$ — env var for runtime ARN +} + +// ───────────────────────────────────────────────────────────────────────────── +// POLICY ENGINE +// ───────────────────────────────────────────────────────────────────────────── + +interface PolicyEngine { + name: string; // @regex ^[A-Za-z][A-Za-z0-9_]{0,47}$ @max 48 + description?: string; // @max 4096 + encryptionKeyArn?: string; + tags?: Record; + policies: Policy[]; // Unique by name +} + +interface Policy { + name: string; // @regex ^[A-Za-z][A-Za-z0-9_]{0,47}$ @max 48 + description?: string; // @max 4096 + statement: string; // Cedar policy statement + sourceFile?: string; + validationMode: ValidationMode; // default 'FAIL_ON_ANY_FINDINGS' +} + +// ───────────────────────────────────────────────────────────────────────────── +// CONFIG BUNDLE +// ───────────────────────────────────────────────────────────────────────────── + +interface ConfigBundle { + name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,99}$ @max 100 + description?: string; // @max 500 + /** Component configurations keyed by component ARN or placeholder (e.g. {{runtime:}}) */ + components: Record; + branchName?: string; // @max 128 — optional branch name for versioning + commitMessage?: string; // @max 500 — optional commit message +} + +interface ComponentConfiguration { + configuration: Record; // Freeform configuration for the component +} + +// ───────────────────────────────────────────────────────────────────────────── +// AB TEST +// ───────────────────────────────────────────────────────────────────────────── + +interface ABTest { + name: string; // @regex ^[a-zA-Z][a-zA-Z0-9_]{0,47}$ @max 48 + description?: string; // @max 200 + gatewayRef: string; // Reference to the gateway (ARN or {{gateway:name}} placeholder) + roleArn?: string; + variants: [ABTestVariant, ABTestVariant]; // Exactly 2 — one 'C' (control) and one 'T1' (treatment). Weights must sum to 100. + evaluationConfig: { + onlineEvaluationConfigArn: string; + }; + trafficAllocationConfig?: { + routeOnHeader: { headerName: string }; + }; + maxDurationDays?: number; // @min 1 @max 90 + enableOnCreate?: boolean; +} + +interface ABTestVariant { + name: ABTestVariantName; + weight: number; // @min 1 @max 100 + variantConfiguration: { + configurationBundle: { + bundleArn: string; + bundleVersion: string; + }; + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// HTTP GATEWAY +// ───────────────────────────────────────────────────────────────────────────── + +/** @internal HTTP gateway auto-created when setting up an AB test. */ +interface HttpGateway { + name: string; // @regex ^[a-zA-Z][a-zA-Z0-9-]{0,47}$ @max 48 + description?: string; // @max 200 + runtimeRef: string; // Reference to a runtime name from spec.runtimes + roleArn?: string; // IAM role ARN — auto-created if omitted } diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index 1b8d1b668..b5b0e55a2 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -15,72 +15,41 @@ import { import { describe, expect, it } from 'vitest'; describe('AgentNameSchema', () => { - it.each(['Agent1', 'myAgent', 'A', 'agent_with_underscores', 'a' + '0'.repeat(47)])( - 'accepts valid name "%s"', - name => { - expect(AgentNameSchema.safeParse(name).success).toBe(true); - } - ); - - it('rejects empty string', () => { - expect(AgentNameSchema.safeParse('').success).toBe(false); + it('accepts valid names', () => { + expect(AgentNameSchema.safeParse('Agent1').success).toBe(true); + expect(AgentNameSchema.safeParse('A').success).toBe(true); + expect(AgentNameSchema.safeParse('agent_with_underscores').success).toBe(true); }); - it('rejects name starting with digit', () => { + it('rejects invalid names', () => { + expect(AgentNameSchema.safeParse('').success).toBe(false); expect(AgentNameSchema.safeParse('1Agent').success).toBe(false); - }); - - it('rejects name with hyphens', () => { expect(AgentNameSchema.safeParse('my-agent').success).toBe(false); }); - it('rejects name exceeding 48 chars', () => { - const name = 'A' + 'b'.repeat(48); - expect(name).toHaveLength(49); - expect(AgentNameSchema.safeParse(name).success).toBe(false); - }); - - it('accepts 48-char name (max)', () => { - const name = 'A' + 'b'.repeat(47); - expect(name).toHaveLength(48); - expect(AgentNameSchema.safeParse(name).success).toBe(true); + it('enforces 48-char boundary', () => { + expect(AgentNameSchema.safeParse('A' + 'b'.repeat(47)).success).toBe(true); + expect(AgentNameSchema.safeParse('A' + 'b'.repeat(48)).success).toBe(false); }); }); describe('EnvVarNameSchema', () => { - it.each(['MY_VAR', '_private', 'UPPER123', 'a', '_'])('accepts valid env var name "%s"', name => { - expect(EnvVarNameSchema.safeParse(name).success).toBe(true); - }); - - it('rejects name starting with digit', () => { + it('accepts valid env var names and rejects invalid', () => { + expect(EnvVarNameSchema.safeParse('MY_VAR').success).toBe(true); + expect(EnvVarNameSchema.safeParse('_private').success).toBe(true); expect(EnvVarNameSchema.safeParse('1VAR').success).toBe(false); - }); - - it('rejects name with hyphens', () => { expect(EnvVarNameSchema.safeParse('MY-VAR').success).toBe(false); - }); - - it('rejects empty string', () => { expect(EnvVarNameSchema.safeParse('').success).toBe(false); }); }); describe('GatewayNameSchema', () => { - it.each(['gateway1', 'my-gateway', 'MyGateway', 'a'])('accepts valid gateway name "%s"', name => { - expect(GatewayNameSchema.safeParse(name).success).toBe(true); - }); - - it('rejects empty string', () => { + it('accepts valid names and rejects invalid', () => { + expect(GatewayNameSchema.safeParse('gateway1').success).toBe(true); + expect(GatewayNameSchema.safeParse('my-gateway').success).toBe(true); expect(GatewayNameSchema.safeParse('').success).toBe(false); - }); - - it('rejects name with underscores', () => { expect(GatewayNameSchema.safeParse('my_gateway').success).toBe(false); - }); - - it('rejects name exceeding 100 chars', () => { - const name = 'a'.repeat(101); - expect(GatewayNameSchema.safeParse(name).success).toBe(false); + expect(GatewayNameSchema.safeParse('a'.repeat(101)).success).toBe(false); }); }); @@ -288,9 +257,10 @@ describe('AgentEnvSpecSchema', () => { }); describe('protocol', () => { - it.each(['HTTP', 'MCP', 'A2A', 'AGUI'])('accepts valid protocol "%s"', mode => { - const result = AgentEnvSpecSchema.safeParse({ ...validPythonAgent, protocol: mode }); - expect(result.success, `Should accept protocol ${mode}`).toBe(true); + it('accepts valid protocols', () => { + expect(AgentEnvSpecSchema.safeParse({ ...validPythonAgent, protocol: 'HTTP' }).success).toBe(true); + expect(AgentEnvSpecSchema.safeParse({ ...validPythonAgent, protocol: 'MCP' }).success).toBe(true); + expect(AgentEnvSpecSchema.safeParse({ ...validPythonAgent, protocol: 'A2A' }).success).toBe(true); }); it('accepts agent without protocol (backwards compat)', () => { @@ -300,7 +270,6 @@ describe('AgentEnvSpecSchema', () => { it('rejects invalid protocol', () => { expect(AgentEnvSpecSchema.safeParse({ ...validPythonAgent, protocol: 'GRPC' }).success).toBe(false); - expect(AgentEnvSpecSchema.safeParse({ ...validPythonAgent, protocol: 'websocket' }).success).toBe(false); }); }); }); @@ -439,12 +408,12 @@ describe('AgentEnvSpecSchema - dockerfile', () => { } }); - it.each(['Dockerfile', 'Dockerfile.dev', 'Dockerfile.gpu-v2', 'my.Dockerfile', 'dockerfile_test'])( - 'accepts valid dockerfile name "%s"', - name => { - expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: name }).success).toBe(true); - } - ); + it('accepts valid dockerfile names', () => { + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: 'Dockerfile' }).success).toBe(true); + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: 'Dockerfile.gpu-v2' }).success).toBe( + true + ); + }); it('rejects dockerfile on CodeZip builds', () => { const result = AgentEnvSpecSchema.safeParse({ ...validCodeZipAgent, dockerfile: 'Dockerfile.custom' }); @@ -454,12 +423,12 @@ describe('AgentEnvSpecSchema - dockerfile', () => { } }); - it.each(['../Dockerfile', '/etc/Dockerfile', 'path/to/Dockerfile', '.hidden'])( - 'rejects path traversal or path separator in dockerfile "%s"', - name => { - expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: name }).success).toBe(false); - } - ); + it('rejects path traversal or path separator in dockerfile', () => { + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: '../Dockerfile' }).success).toBe(false); + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: 'path/to/Dockerfile' }).success).toBe( + false + ); + }); it('rejects empty string dockerfile', () => { expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: '' }).success).toBe(false); @@ -481,12 +450,12 @@ describe('AgentEnvSpecSchema - dockerfile', () => { expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: maxName }).success).toBe(true); }); - it.each(['\\\\server\\share', 'Dockerfile\\..\\secret', '..\\Dockerfile'])( - 'rejects backslash path traversal in dockerfile "%s"', - name => { - expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: name }).success).toBe(false); - } - ); + it('rejects backslash path traversal in dockerfile', () => { + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: '\\\\server\\share' }).success).toBe( + false + ); + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: '..\\Dockerfile' }).success).toBe(false); + }); }); describe('AgentEnvSpecSchema - lifecycleConfiguration', () => { @@ -544,34 +513,21 @@ describe('AgentEnvSpecSchema - lifecycleConfiguration', () => { }); describe('RuntimeEndpointNameSchema', () => { - it.each(['prod', 'staging', 'myEndpoint', 'v1', 'A', 'a' + '0'.repeat(47)])( - 'accepts valid endpoint name "%s"', - name => { - expect(RuntimeEndpointNameSchema.safeParse(name).success).toBe(true); - } - ); - - it('rejects empty string', () => { - expect(RuntimeEndpointNameSchema.safeParse('').success).toBe(false); + it('accepts valid names', () => { + expect(RuntimeEndpointNameSchema.safeParse('prod').success).toBe(true); + expect(RuntimeEndpointNameSchema.safeParse('myEndpoint').success).toBe(true); }); - it('rejects name starting with digit', () => { + it('rejects invalid names', () => { + expect(RuntimeEndpointNameSchema.safeParse('').success).toBe(false); expect(RuntimeEndpointNameSchema.safeParse('1prod').success).toBe(false); - }); - - it('rejects name with hyphens', () => { expect(RuntimeEndpointNameSchema.safeParse('my-endpoint').success).toBe(false); - }); - - it('rejects name with special characters', () => { expect(RuntimeEndpointNameSchema.safeParse('prod!').success).toBe(false); - expect(RuntimeEndpointNameSchema.safeParse('my@endpoint').success).toBe(false); }); - it('rejects name exceeding 48 chars', () => { - const name = 'A' + 'b'.repeat(48); - expect(name).toHaveLength(49); - expect(RuntimeEndpointNameSchema.safeParse(name).success).toBe(false); + it('enforces 48-char boundary', () => { + expect(RuntimeEndpointNameSchema.safeParse('A' + 'b'.repeat(47)).success).toBe(true); + expect(RuntimeEndpointNameSchema.safeParse('A' + 'b'.repeat(48)).success).toBe(false); }); }); diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 08aaf3b12..9fca9b6d8 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -9,69 +9,31 @@ import { import { describe, expect, it } from 'vitest'; describe('ProjectNameSchema', () => { - describe('valid names', () => { - it.each(['A', 'MyProject', 'test1', 'a1b2c3', 'ALLCAPS', 'abcdefghijklmnopqrstuvw'])('accepts "%s"', name => { - expect(ProjectNameSchema.safeParse(name).success).toBe(true); - }); + it('accepts valid names', () => { + expect(ProjectNameSchema.safeParse('A').success).toBe(true); + expect(ProjectNameSchema.safeParse('MyProject').success).toBe(true); + expect(ProjectNameSchema.safeParse('a1b2c3').success).toBe(true); }); - describe('length validation', () => { - it('rejects empty string', () => { - expect(ProjectNameSchema.safeParse('').success).toBe(false); - }); - - it('accepts 1-character name', () => { - expect(ProjectNameSchema.safeParse('A').success).toBe(true); - }); - - it('accepts 23-character name (max)', () => { - const name = 'A' + 'b'.repeat(22); - expect(name).toHaveLength(23); - expect(ProjectNameSchema.safeParse(name).success).toBe(true); - }); - - it('rejects 24-character name', () => { - const name = 'A' + 'b'.repeat(23); - expect(name).toHaveLength(24); - expect(ProjectNameSchema.safeParse(name).success).toBe(false); - }); + it('enforces 23-char boundary', () => { + expect(ProjectNameSchema.safeParse('A' + 'b'.repeat(22)).success).toBe(true); + expect(ProjectNameSchema.safeParse('A' + 'b'.repeat(23)).success).toBe(false); }); - describe('format validation', () => { - it('rejects name starting with a digit', () => { - expect(ProjectNameSchema.safeParse('1project').success).toBe(false); - }); - - it('rejects name with underscores', () => { - expect(ProjectNameSchema.safeParse('my_project').success).toBe(false); - }); - - it('rejects name with hyphens', () => { - expect(ProjectNameSchema.safeParse('my-project').success).toBe(false); - }); - - it('rejects name with spaces', () => { - expect(ProjectNameSchema.safeParse('my project').success).toBe(false); - }); - - it('rejects name with special characters', () => { - expect(ProjectNameSchema.safeParse('my.project').success).toBe(false); - expect(ProjectNameSchema.safeParse('my@project').success).toBe(false); - }); + it('rejects invalid formats', () => { + expect(ProjectNameSchema.safeParse('').success).toBe(false); + expect(ProjectNameSchema.safeParse('1project').success).toBe(false); + expect(ProjectNameSchema.safeParse('my_project').success).toBe(false); + expect(ProjectNameSchema.safeParse('my-project').success).toBe(false); + expect(ProjectNameSchema.safeParse('my project').success).toBe(false); + expect(ProjectNameSchema.safeParse('my.project').success).toBe(false); }); - describe('reserved name validation', () => { - it.each(['anthropic', 'Anthropic', 'ANTHROPIC', 'openai', 'boto3', 'strands', 'test', 'pip', 'uv'])( - 'rejects reserved name "%s"', - name => { - // Some reserved names may also fail the regex (e.g., too long). We just check it doesn't pass. - expect(ProjectNameSchema.safeParse(name).success).toBe(false); - } - ); - - it('accepts non-reserved name', () => { - expect(ProjectNameSchema.safeParse('MyAgent').success).toBe(true); - }); + it('rejects reserved names (case-insensitive)', () => { + expect(ProjectNameSchema.safeParse('anthropic').success).toBe(false); + expect(ProjectNameSchema.safeParse('Anthropic').success).toBe(false); + expect(ProjectNameSchema.safeParse('openai').success).toBe(false); + expect(ProjectNameSchema.safeParse('MyAgent').success).toBe(true); }); }); diff --git a/src/schema/schemas/__tests__/aws-targets.test.ts b/src/schema/schemas/__tests__/aws-targets.test.ts index cd84f9cb3..5da4df463 100644 --- a/src/schema/schemas/__tests__/aws-targets.test.ts +++ b/src/schema/schemas/__tests__/aws-targets.test.ts @@ -8,42 +8,32 @@ import { import { describe, expect, it } from 'vitest'; describe('AgentCoreRegionSchema', () => { - const validRegions = [ - 'ap-northeast-1', - 'ap-northeast-2', - 'ap-south-1', - 'ap-southeast-1', - 'ap-southeast-2', - 'ca-central-1', - 'eu-central-1', - 'eu-north-1', - 'eu-west-1', - 'eu-west-2', - 'eu-west-3', - 'sa-east-1', - 'us-east-1', - 'us-east-2', - 'us-west-2', - 'us-gov-west-1', - ]; - - it.each(validRegions)('accepts valid region "%s"', region => { - expect(AgentCoreRegionSchema.safeParse(region).success).toBe(true); - }); - - it('rejects unsupported regions', () => { - expect(AgentCoreRegionSchema.safeParse('us-west-1').success).toBe(false); - expect(AgentCoreRegionSchema.safeParse('af-south-1').success).toBe(false); - expect(AgentCoreRegionSchema.safeParse('me-south-1').success).toBe(false); + it('enumerates all supported regions', () => { + expect(AgentCoreRegionSchema.options).toEqual([ + 'ap-northeast-1', + 'ap-northeast-2', + 'ap-south-1', + 'ap-southeast-1', + 'ap-southeast-2', + 'ca-central-1', + 'eu-central-1', + 'eu-north-1', + 'eu-west-1', + 'eu-west-2', + 'eu-west-3', + 'sa-east-1', + 'us-east-1', + 'us-east-2', + 'us-west-2', + 'us-gov-west-1', + ]); }); - it('rejects empty string', () => { + it('rejects unsupported regions and invalid values', () => { + expect(AgentCoreRegionSchema.safeParse('us-west-1').success).toBe(false); + expect(AgentCoreRegionSchema.safeParse('af-south-1').success).toBe(false); expect(AgentCoreRegionSchema.safeParse('').success).toBe(false); - }); - - it('rejects non-string values', () => { expect(AgentCoreRegionSchema.safeParse(123).success).toBe(false); - expect(AgentCoreRegionSchema.safeParse(null).success).toBe(false); }); }); diff --git a/src/schema/schemas/__tests__/deployed-state.test.ts b/src/schema/schemas/__tests__/deployed-state.test.ts index f1791712b..4c4483392 100644 --- a/src/schema/schemas/__tests__/deployed-state.test.ts +++ b/src/schema/schemas/__tests__/deployed-state.test.ts @@ -196,43 +196,27 @@ describe('VpcConfigSchema', () => { }); describe('CredentialDeployedStateSchema', () => { - it('accepts valid credential state with all fields', () => { - const result = CredentialDeployedStateSchema.safeParse({ - credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', - clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', - callbackUrl: 'https://callback.example.com', - }); - expect(result.success).toBe(true); - }); - - it('accepts credential state with only required credentialProviderArn', () => { - const result = CredentialDeployedStateSchema.safeParse({ - credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', - }); - expect(result.success).toBe(true); - }); - - it('accepts credential state with optional clientSecretArn', () => { - const result = CredentialDeployedStateSchema.safeParse({ - credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', - clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', - }); - expect(result.success).toBe(true); - }); - - it('accepts credential state with optional callbackUrl', () => { - const result = CredentialDeployedStateSchema.safeParse({ - credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', - callbackUrl: 'https://callback.example.com', - }); - expect(result.success).toBe(true); + it('accepts valid credential state with all fields and required-only', () => { + expect( + CredentialDeployedStateSchema.safeParse({ + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', + callbackUrl: 'https://callback.example.com', + }).success + ).toBe(true); + expect( + CredentialDeployedStateSchema.safeParse({ + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + }).success + ).toBe(true); }); it('rejects credential state without credentialProviderArn', () => { - const result = CredentialDeployedStateSchema.safeParse({ - clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', - }); - expect(result.success).toBe(false); + expect( + CredentialDeployedStateSchema.safeParse({ + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', + }).success + ).toBe(false); }); }); diff --git a/src/schema/schemas/__tests__/mcp-defs.test.ts b/src/schema/schemas/__tests__/mcp-defs.test.ts index a673bbd56..f411ca99a 100644 --- a/src/schema/schemas/__tests__/mcp-defs.test.ts +++ b/src/schema/schemas/__tests__/mcp-defs.test.ts @@ -7,34 +7,22 @@ import { import { describe, expect, it } from 'vitest'; describe('ToolNameSchema', () => { - it.each(['myTool', 'get_user', 'search-results', 'A'])('accepts valid name "%s"', name => { - expect(ToolNameSchema.safeParse(name).success).toBe(true); + it('accepts valid names', () => { + expect(ToolNameSchema.safeParse('myTool').success).toBe(true); + expect(ToolNameSchema.safeParse('get_user').success).toBe(true); + expect(ToolNameSchema.safeParse('search-results').success).toBe(true); }); - it('rejects empty string', () => { + it('rejects invalid names', () => { expect(ToolNameSchema.safeParse('').success).toBe(false); - }); - - it('rejects name starting with digit', () => { expect(ToolNameSchema.safeParse('1tool').success).toBe(false); - }); - - it('rejects name starting with hyphen', () => { expect(ToolNameSchema.safeParse('-tool').success).toBe(false); + expect(ToolNameSchema.safeParse('my.tool').success).toBe(false); }); - it('rejects name exceeding 128 chars', () => { - const name = 'a'.repeat(129); - expect(ToolNameSchema.safeParse(name).success).toBe(false); - }); - - it('accepts 128-char name (max)', () => { - const name = 'a'.repeat(128); - expect(ToolNameSchema.safeParse(name).success).toBe(true); - }); - - it('rejects name with dots', () => { - expect(ToolNameSchema.safeParse('my.tool').success).toBe(false); + it('enforces 128-char boundary', () => { + expect(ToolNameSchema.safeParse('a'.repeat(128)).success).toBe(true); + expect(ToolNameSchema.safeParse('a'.repeat(129)).success).toBe(false); }); }); diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index 128b43dac..0ab33c2a4 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -21,12 +21,16 @@ import { import { describe, expect, it } from 'vitest'; describe('GatewayTargetTypeSchema', () => { - it.each(['lambda', 'mcpServer', 'openApiSchema', 'smithyModel', 'apiGateway', 'lambdaFunctionArn'])( - 'accepts "%s"', - type => { - expect(GatewayTargetTypeSchema.safeParse(type).success).toBe(true); - } - ); + it('enumerates all target types', () => { + expect(GatewayTargetTypeSchema.options).toEqual([ + 'lambda', + 'mcpServer', + 'openApiSchema', + 'smithyModel', + 'apiGateway', + 'lambdaFunctionArn', + ]); + }); it('rejects invalid type', () => { expect(GatewayTargetTypeSchema.safeParse('http').success).toBe(false); @@ -34,29 +38,17 @@ describe('GatewayTargetTypeSchema', () => { }); describe('GatewayAuthorizerTypeSchema', () => { - it('accepts NONE', () => { + it('accepts valid types and rejects invalid', () => { expect(GatewayAuthorizerTypeSchema.safeParse('NONE').success).toBe(true); - }); - - it('accepts CUSTOM_JWT', () => { expect(GatewayAuthorizerTypeSchema.safeParse('CUSTOM_JWT').success).toBe(true); - }); - - it('rejects other types', () => { expect(GatewayAuthorizerTypeSchema.safeParse('IAM').success).toBe(false); }); }); describe('McpImplLanguageSchema', () => { - it('accepts TypeScript', () => { + it('accepts valid languages and rejects invalid', () => { expect(McpImplLanguageSchema.safeParse('TypeScript').success).toBe(true); - }); - - it('accepts Python', () => { expect(McpImplLanguageSchema.safeParse('Python').success).toBe(true); - }); - - it('rejects other languages', () => { expect(McpImplLanguageSchema.safeParse('Go').success).toBe(false); }); }); @@ -345,15 +337,9 @@ describe('AgentCoreGatewayTargetSchema', () => { }); describe('GatewayExceptionLevelSchema', () => { - it('accepts NONE', () => { + it('accepts valid levels and rejects invalid', () => { expect(GatewayExceptionLevelSchema.safeParse('NONE').success).toBe(true); - }); - - it('accepts DEBUG', () => { expect(GatewayExceptionLevelSchema.safeParse('DEBUG').success).toBe(true); - }); - - it('rejects invalid level', () => { expect(GatewayExceptionLevelSchema.safeParse('VERBOSE').success).toBe(false); }); }); diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index e61196348..10d164a2c 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -9,7 +9,10 @@ import { isReservedProjectName } from '../constants'; import { AgentEnvSpecSchema } from './agent-env'; import { AgentCoreGatewaySchema, AgentCoreGatewayTargetSchema, AgentCoreMcpRuntimeToolSchema } from './mcp'; +import { ABTestSchema } from './primitives/ab-test'; +import { ConfigBundleSchema } from './primitives/config-bundle'; import { EvaluationLevelSchema, EvaluatorConfigSchema, EvaluatorNameSchema } from './primitives/evaluator'; +import { HttpGatewaySchema } from './primitives/http-gateway'; import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, DEFAULT_STRATEGY_NAMESPACES, @@ -43,11 +46,18 @@ export type { RatingScale, } from './primitives/evaluator'; export { BedrockModelIdSchema, isValidBedrockModelId, EvaluatorNameSchema } from './primitives/evaluator'; +export { ConfigBundleSchema }; +export type { ComponentConfiguration, ComponentConfigurationMap, ConfigBundle } from './primitives/config-bundle'; +export { ConfigBundleNameSchema, ComponentConfigurationMapSchema } from './primitives/config-bundle'; export { PolicyEngineSchema }; export type { Policy, PolicyEngine, ValidationMode } from './primitives/policy'; export { PolicyEngineNameSchema, PolicyNameSchema, PolicySchema, ValidationModeSchema } from './primitives/policy'; export { TagsSchema }; export type { Tags } from './primitives/tags'; +export type { ABTestMode, TargetRef, GatewayFilter, PerVariantOnlineEvaluationConfig } from './primitives/ab-test'; +export { ABTestModeSchema, TargetRefSchema, GatewayFilterSchema } from './primitives/ab-test'; +export type { HttpGatewayTarget } from './primitives/http-gateway'; +export { HttpGatewayTargetSchema } from './primitives/http-gateway'; // ============================================================================ // ManagedBy Schema @@ -312,6 +322,36 @@ export const AgentCoreProjectSpecSchema = z name => `Duplicate policy engine name: ${name}` ) ), + + configBundles: z + .array(ConfigBundleSchema) + .default([]) + .superRefine( + uniqueBy( + bundle => bundle.name, + name => `Duplicate config bundle name: ${name}` + ) + ), + + abTests: z + .array(ABTestSchema) + .default([]) + .superRefine( + uniqueBy( + test => test.name, + name => `Duplicate AB test name: ${name}` + ) + ), + + httpGateways: z + .array(HttpGatewaySchema) + .default([]) + .superRefine( + uniqueBy( + gw => gw.name, + name => `Duplicate HTTP gateway name: ${name}` + ) + ), }) .strict() .superRefine((spec, ctx) => { @@ -339,6 +379,70 @@ export const AgentCoreProjectSpecSchema = z } } } + + // Validate HTTP gateway runtimeRef references + for (const gw of spec.httpGateways ?? []) { + const runtimeExists = spec.runtimes.some(r => r.name === gw.runtimeRef); + if (!runtimeExists) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `HTTP gateway "${gw.name}" references unknown runtime "${gw.runtimeRef}"`, + }); + } + } + + // Validate AB test gateway references + for (const test of spec.abTests ?? []) { + const gwField = test.gatewayRef; + if (gwField && typeof gwField === 'string') { + const match = /^\{\{gateway:(.+)\}\}$/.exec(gwField); + if (match) { + const gwName = match[1]; + const gwExists = (spec.httpGateways ?? []).some(gw => gw.name === gwName); + if (!gwExists) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `AB test "${test.name}" references gateway "${gwName}" which does not exist in httpGateways`, + }); + } + + // For target-based AB tests, validate target names exist in the gateway's targets array + if (test.mode === 'target-based') { + const gw = (spec.httpGateways ?? []).find(g => g.name === gwName); + if (gw) { + const gwTargetNames = new Set((gw.targets ?? []).map(t => t.name)); + for (const variant of test.variants) { + const targetName = variant.variantConfiguration.target?.targetName; + if (targetName && !gwTargetNames.has(targetName)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `AB test "${test.name}" variant "${variant.name}" references target "${targetName}" which does not exist in gateway "${gwName}" targets`, + }); + } + } + } + } + } + } + } + + // Validate HTTP gateway target runtimeRef and qualifier references + for (const gw of spec.httpGateways ?? []) { + for (const target of gw.targets ?? []) { + const runtime = spec.runtimes.find(r => r.name === target.runtimeRef); + if (!runtime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `HTTP gateway "${gw.name}" target "${target.name}" references unknown runtime "${target.runtimeRef}"`, + }); + } else if (target.qualifier && target.qualifier !== 'DEFAULT' && !runtime.endpoints?.[target.qualifier]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `HTTP gateway "${gw.name}" target "${target.name}" references qualifier "${target.qualifier}" which is not an endpoint on runtime "${target.runtimeRef}"`, + }); + } + } + } }); export type AgentCoreProjectSpec = z.infer; diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 6eeff21b7..a37469799 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -166,10 +166,58 @@ export const OnlineEvalDeployedStateSchema = z.object({ onlineEvaluationConfigId: z.string().min(1), onlineEvaluationConfigArn: z.string().min(1), executionStatus: z.enum(['ENABLED', 'DISABLED']).optional(), + /** Agent name this online eval config monitors. */ + agent: z.string().min(1).optional(), + /** Runtime endpoint name scoped to this online eval config. */ + endpoint: z.string().min(1).optional(), }); export type OnlineEvalDeployedState = z.infer; +// ============================================================================ +// Configuration Bundle Deployed State +// ============================================================================ + +export const ConfigBundleDeployedStateSchema = z.object({ + bundleId: z.string().min(1), + bundleArn: z.string().min(1), + versionId: z.string().min(1), +}); + +export type ConfigBundleDeployedState = z.infer; + +// ============================================================================ +// AB Test Deployed State +// ============================================================================ + +export const ABTestDeployedStateSchema = z.object({ + abTestId: z.string().min(1), + abTestArn: z.string().min(1), + /** IAM role ARN used by this AB test. */ + roleArn: z.string().min(1).optional(), + /** Whether the CLI auto-created this role (true = CLI should delete on cleanup). */ + roleCreatedByCli: z.boolean().optional(), + /** SHA-256 hash of the AB test configuration for change detection. */ + configHash: z.string().optional(), +}); + +export type ABTestDeployedState = z.infer; + +// ============================================================================ +// HTTP Gateway Deployed State +// ============================================================================ + +export const HttpGatewayDeployedStateSchema = z.object({ + gatewayId: z.string().min(1), + gatewayArn: z.string().min(1), + gatewayUrl: z.string().optional(), + targetId: z.string().min(1).optional(), + roleArn: z.string().min(1).optional(), + roleCreatedByCli: z.boolean().optional(), +}); + +export type HttpGatewayDeployedState = z.infer; + // ============================================================================ // Runtime Endpoint Deployed State // ============================================================================ @@ -193,6 +241,9 @@ export const DeployedResourceStateSchema = z.object({ credentials: z.record(z.string(), CredentialDeployedStateSchema).optional(), evaluators: z.record(z.string(), EvaluatorDeployedStateSchema).optional(), onlineEvalConfigs: z.record(z.string(), OnlineEvalDeployedStateSchema).optional(), + configBundles: z.record(z.string(), ConfigBundleDeployedStateSchema).optional(), + abTests: z.record(z.string(), ABTestDeployedStateSchema).optional(), + httpGateways: z.record(z.string(), HttpGatewayDeployedStateSchema).optional(), policyEngines: z.record(z.string(), PolicyEngineDeployedStateSchema).optional(), policies: z.record(z.string(), PolicyDeployedStateSchema).optional(), runtimeEndpoints: z.record(z.string(), RuntimeEndpointDeployedStateSchema).optional(), diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index aaaa4e9cd..42cd89810 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -58,8 +58,8 @@ export const TARGET_TYPE_AUTH_CONFIG: Record< smithyModel: { authRequired: false, validAuthTypes: [], iamRoleFallback: true }, apiGateway: { authRequired: false, validAuthTypes: ['API_KEY', 'NONE'], iamRoleFallback: true }, mcpServer: { authRequired: false, validAuthTypes: ['OAUTH', 'NONE'], iamRoleFallback: false }, - lambda: { authRequired: false, validAuthTypes: ['OAUTH', 'NONE'], iamRoleFallback: false }, - lambdaFunctionArn: { authRequired: false, validAuthTypes: [], iamRoleFallback: true }, + lambda: { authRequired: false, validAuthTypes: ['OAUTH', 'NONE'], iamRoleFallback: true }, + lambdaFunctionArn: { authRequired: false, validAuthTypes: ['OAUTH', 'NONE'], iamRoleFallback: true }, }; // ============================================================================ @@ -580,6 +580,8 @@ export type GatewayPolicyEngineConfiguration = z.infer { + it('accepts valid name starting with letter', () => { + expect(ABTestNameSchema.safeParse('MyTest_1').success).toBe(true); + }); + + it('rejects empty string', () => { + expect(ABTestNameSchema.safeParse('').success).toBe(false); + }); + + it('rejects name starting with number', () => { + expect(ABTestNameSchema.safeParse('1test').success).toBe(false); + }); + + it('rejects name with hyphens', () => { + expect(ABTestNameSchema.safeParse('my-test').success).toBe(false); + }); + + it('rejects name over 48 chars', () => { + expect(ABTestNameSchema.safeParse('a'.repeat(49)).success).toBe(false); + }); + + it('accepts name at 48 chars', () => { + expect(ABTestNameSchema.safeParse('a'.repeat(48)).success).toBe(true); + }); +}); + +describe('ABTestDescriptionSchema', () => { + it('accepts undefined (optional)', () => { + expect(ABTestDescriptionSchema.safeParse(undefined).success).toBe(true); + }); + + it('rejects empty string', () => { + expect(ABTestDescriptionSchema.safeParse('').success).toBe(false); + }); + + it('rejects string over 200 chars', () => { + expect(ABTestDescriptionSchema.safeParse('x'.repeat(201)).success).toBe(false); + }); + + it('accepts string at exactly 200 chars', () => { + expect(ABTestDescriptionSchema.safeParse('x'.repeat(200)).success).toBe(true); + }); +}); + +describe('VariantNameSchema', () => { + it('accepts C', () => { + expect(VariantNameSchema.safeParse('C').success).toBe(true); + }); + + it('accepts T1', () => { + expect(VariantNameSchema.safeParse('T1').success).toBe(true); + }); + + it('rejects other names', () => { + expect(VariantNameSchema.safeParse('T2').success).toBe(false); + }); +}); + +describe('VariantWeightSchema', () => { + it('accepts 1', () => { + expect(VariantWeightSchema.safeParse(1).success).toBe(true); + }); + + it('accepts 100', () => { + expect(VariantWeightSchema.safeParse(100).success).toBe(true); + }); + + it('rejects 0', () => { + expect(VariantWeightSchema.safeParse(0).success).toBe(false); + }); + + it('rejects 101', () => { + expect(VariantWeightSchema.safeParse(101).success).toBe(false); + }); + + it('rejects non-integer', () => { + expect(VariantWeightSchema.safeParse(50.5).success).toBe(false); + }); +}); + +describe('ABTestSchema', () => { + const validABTest = { + name: 'TestOne', + gatewayRef: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-123', + variants: [ + { + name: 'C', + weight: 80, + variantConfiguration: { + configurationBundle: { bundleArn: 'arn:bundle:control', bundleVersion: 'v1' }, + }, + }, + { + name: 'T1', + weight: 20, + variantConfiguration: { + configurationBundle: { bundleArn: 'arn:bundle:treatment', bundleVersion: 'v1' }, + }, + }, + ], + evaluationConfig: { onlineEvaluationConfigArn: 'arn:eval:config' }, + }; + + it('accepts valid minimal AB test', () => { + expect(ABTestSchema.safeParse(validABTest).success).toBe(true); + }); + + it('accepts with optional fields', () => { + const result = ABTestSchema.safeParse({ + ...validABTest, + description: 'A test', + roleArn: 'arn:aws:iam::123:role/MyRole', + maxDurationDays: 30, + enableOnCreate: true, + trafficAllocationConfig: { routeOnHeader: { headerName: 'X-AB-Route' } }, + }); + expect(result.success).toBe(true); + }); + + it('rejects with only 1 variant', () => { + const result = ABTestSchema.safeParse({ + ...validABTest, + variants: [validABTest.variants[0]], + }); + expect(result.success).toBe(false); + }); + + it('rejects with 3 variants', () => { + const result = ABTestSchema.safeParse({ + ...validABTest, + variants: [...validABTest.variants, validABTest.variants[0]], + }); + expect(result.success).toBe(false); + }); + + it('rejects maxDurationDays outside 1-90', () => { + expect(ABTestSchema.safeParse({ ...validABTest, maxDurationDays: 0 }).success).toBe(false); + expect(ABTestSchema.safeParse({ ...validABTest, maxDurationDays: 91 }).success).toBe(false); + }); + + describe('variant weight sum validation', () => { + it('accepts weights summing to 100 (50/50)', () => { + const test = { + ...validABTest, + variants: [ + { ...validABTest.variants[0], weight: 50 }, + { ...validABTest.variants[1], weight: 50 }, + ], + }; + expect(ABTestSchema.safeParse(test).success).toBe(true); + }); + + it('accepts weights summing to 100 (1/99)', () => { + const test = { + ...validABTest, + variants: [ + { ...validABTest.variants[0], weight: 1 }, + { ...validABTest.variants[1], weight: 99 }, + ], + }; + expect(ABTestSchema.safeParse(test).success).toBe(true); + }); + + it('rejects weights summing to 150', () => { + const test = { + ...validABTest, + variants: [ + { ...validABTest.variants[0], weight: 80 }, + { ...validABTest.variants[1], weight: 70 }, + ], + }; + const result = ABTestSchema.safeParse(test); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('sum to 100'))).toBe(true); + } + }); + + it('rejects weights summing to 2', () => { + const test = { + ...validABTest, + variants: [ + { ...validABTest.variants[0], weight: 1 }, + { ...validABTest.variants[1], weight: 1 }, + ], + }; + expect(ABTestSchema.safeParse(test).success).toBe(false); + }); + }); + + describe('variant uniqueness validation', () => { + it('rejects two control variants', () => { + const test = { + ...validABTest, + variants: [ + { ...validABTest.variants[0], name: 'C', weight: 50 }, + { ...validABTest.variants[1], name: 'C', weight: 50 }, + ], + }; + const result = ABTestSchema.safeParse(test); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('control (C) and one treatment (T1)'))).toBe(true); + } + }); + + it('rejects two treatment variants', () => { + const test = { + ...validABTest, + variants: [ + { ...validABTest.variants[0], name: 'T1', weight: 50 }, + { ...validABTest.variants[1], name: 'T1', weight: 50 }, + ], + }; + const result = ABTestSchema.safeParse(test); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/src/schema/schemas/primitives/__tests__/evaluator.test.ts b/src/schema/schemas/primitives/__tests__/evaluator.test.ts index 9147c5cf9..f378e526b 100644 --- a/src/schema/schemas/primitives/__tests__/evaluator.test.ts +++ b/src/schema/schemas/primitives/__tests__/evaluator.test.ts @@ -9,12 +9,11 @@ import { import { describe, expect, it } from 'vitest'; describe('EvaluationLevelSchema', () => { - it.each(['SESSION', 'TRACE', 'TOOL_CALL'])('accepts %s', level => { - expect(EvaluationLevelSchema.safeParse(level).success).toBe(true); - }); - - it.each(['session', 'INVALID', '', 'SPAN'])('rejects %s', level => { - expect(EvaluationLevelSchema.safeParse(level).success).toBe(false); + it('accepts valid levels and rejects invalid', () => { + expect(EvaluationLevelSchema.safeParse('SESSION').success).toBe(true); + expect(EvaluationLevelSchema.safeParse('TOOL_CALL').success).toBe(true); + expect(EvaluationLevelSchema.safeParse('session').success).toBe(false); + expect(EvaluationLevelSchema.safeParse('INVALID').success).toBe(false); }); }); diff --git a/src/schema/schemas/primitives/__tests__/http-gateway.test.ts b/src/schema/schemas/primitives/__tests__/http-gateway.test.ts new file mode 100644 index 000000000..4fd885df1 --- /dev/null +++ b/src/schema/schemas/primitives/__tests__/http-gateway.test.ts @@ -0,0 +1,82 @@ +import { HttpGatewayNameSchema, HttpGatewaySchema } from '../http-gateway'; +import { describe, expect, it } from 'vitest'; + +describe('HttpGatewayNameSchema', () => { + it('accepts valid name starting with letter', () => { + expect(HttpGatewayNameSchema.safeParse('MyGateway1').success).toBe(true); + }); + + it('accepts name with hyphens', () => { + expect(HttpGatewayNameSchema.safeParse('my-gateway').success).toBe(true); + }); + + it('rejects empty string', () => { + expect(HttpGatewayNameSchema.safeParse('').success).toBe(false); + }); + + it('rejects name starting with number', () => { + expect(HttpGatewayNameSchema.safeParse('1gateway').success).toBe(false); + }); + + it('rejects name with underscores', () => { + expect(HttpGatewayNameSchema.safeParse('my_gateway').success).toBe(false); + }); + + it('accepts name longer than 24 chars', () => { + expect(HttpGatewayNameSchema.safeParse('a'.repeat(25)).success).toBe(true); + }); + + it('accepts name at 47 chars (room for 1-char project name + hyphen)', () => { + expect(HttpGatewayNameSchema.safeParse('a' + 'b'.repeat(46)).success).toBe(true); + }); +}); + +describe('HttpGatewaySchema', () => { + const validHttpGateway = { + name: 'MyGateway', + runtimeRef: 'my-runtime', + }; + + it('accepts valid HTTP gateway with required fields', () => { + expect(HttpGatewaySchema.safeParse(validHttpGateway).success).toBe(true); + }); + + it('accepts valid HTTP gateway with all optional fields', () => { + const result = HttpGatewaySchema.safeParse({ + ...validHttpGateway, + description: 'A test gateway', + roleArn: 'arn:aws:iam::123456789012:role/MyRole', + }); + expect(result.success).toBe(true); + }); + + it('rejects missing name', () => { + const { name: _, ...withoutName } = validHttpGateway; + expect(HttpGatewaySchema.safeParse(withoutName).success).toBe(false); + }); + + it('rejects missing runtimeRef', () => { + const { runtimeRef: _, ...withoutRuntimeRef } = validHttpGateway; + expect(HttpGatewaySchema.safeParse(withoutRuntimeRef).success).toBe(false); + }); + + it('accepts name longer than 24 chars (no standalone max cap)', () => { + expect(HttpGatewaySchema.safeParse({ ...validHttpGateway, name: 'a' + 'b'.repeat(30) }).success).toBe(true); + }); + + it('rejects name starting with number', () => { + expect(HttpGatewaySchema.safeParse({ ...validHttpGateway, name: '1Gateway' }).success).toBe(false); + }); + + it('rejects name with invalid characters (underscores)', () => { + expect(HttpGatewaySchema.safeParse({ ...validHttpGateway, name: 'my_gateway' }).success).toBe(false); + }); + + it('rejects extra unknown fields (.strict())', () => { + const result = HttpGatewaySchema.safeParse({ + ...validHttpGateway, + unknownField: 'should fail', + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/schema/schemas/primitives/__tests__/memory.test.ts b/src/schema/schemas/primitives/__tests__/memory.test.ts index 4b37eb646..082c32fa6 100644 --- a/src/schema/schemas/primitives/__tests__/memory.test.ts +++ b/src/schema/schemas/primitives/__tests__/memory.test.ts @@ -2,43 +2,16 @@ import { DEFAULT_STRATEGY_NAMESPACES, MemoryStrategySchema, MemoryStrategyTypeSc import { describe, expect, it } from 'vitest'; describe('MemoryStrategyTypeSchema', () => { - describe('valid strategy types', () => { - it('accepts SEMANTIC', () => { - expect(MemoryStrategyTypeSchema.safeParse('SEMANTIC').success).toBe(true); - }); - - it('accepts SUMMARIZATION', () => { - expect(MemoryStrategyTypeSchema.safeParse('SUMMARIZATION').success).toBe(true); - }); - - it('accepts USER_PREFERENCE', () => { - expect(MemoryStrategyTypeSchema.safeParse('USER_PREFERENCE').success).toBe(true); - }); - - it('accepts EPISODIC', () => { - expect(MemoryStrategyTypeSchema.safeParse('EPISODIC').success).toBe(true); - }); + it('accepts valid strategy types and rejects invalid', () => { + expect(MemoryStrategyTypeSchema.safeParse('SEMANTIC').success).toBe(true); + expect(MemoryStrategyTypeSchema.safeParse('EPISODIC').success).toBe(true); + expect(MemoryStrategyTypeSchema.safeParse('CUSTOM').success).toBe(false); + expect(MemoryStrategyTypeSchema.safeParse('semantic').success).toBe(false); }); - describe('invalid strategy types', () => { - // Issue #235: CUSTOM strategy has been removed - it('rejects CUSTOM strategy', () => { - const result = MemoryStrategyTypeSchema.safeParse('CUSTOM'); - expect(result.success).toBe(false); - }); - - it('rejects arbitrary invalid strategies', () => { - expect(MemoryStrategyTypeSchema.safeParse('INVALID').success).toBe(false); - expect(MemoryStrategyTypeSchema.safeParse('').success).toBe(false); - expect(MemoryStrategyTypeSchema.safeParse('semantic').success).toBe(false); // lowercase - }); - }); - - describe('schema options', () => { - it('contains four valid strategies including EPISODIC', () => { - expect(MemoryStrategyTypeSchema.options).toEqual(['SEMANTIC', 'SUMMARIZATION', 'USER_PREFERENCE', 'EPISODIC']); - expect(MemoryStrategyTypeSchema.options).not.toContain('CUSTOM'); - }); + it('contains four valid strategies including EPISODIC', () => { + expect(MemoryStrategyTypeSchema.options).toEqual(['SEMANTIC', 'SUMMARIZATION', 'USER_PREFERENCE', 'EPISODIC']); + expect(MemoryStrategyTypeSchema.options).not.toContain('CUSTOM'); }); }); diff --git a/src/schema/schemas/primitives/ab-test.ts b/src/schema/schemas/primitives/ab-test.ts new file mode 100644 index 000000000..ec04ab4f7 --- /dev/null +++ b/src/schema/schemas/primitives/ab-test.ts @@ -0,0 +1,147 @@ +import { z } from 'zod'; + +// ============================================================================ +// AB Test Types +// ============================================================================ + +export const ABTestNameSchema = z + .string() + .min(1, 'Name is required') + .max(48) + .regex( + /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, + 'Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)' + ); + +export const ABTestDescriptionSchema = z.string().min(1).max(200).optional(); + +export const ABTestModeSchema = z.enum(['config-bundle', 'target-based']).optional().default('config-bundle'); + +export type ABTestMode = z.infer; + +export const VariantNameSchema = z.enum(['C', 'T1']); + +export const VariantWeightSchema = z.number().int().min(1).max(100); + +// ── Config Bundle variant configuration ──────────────────────────────────── + +export const ConfigurationBundleRefSchema = z.object({ + bundleArn: z.string().min(1), + bundleVersion: z.string().min(1), +}); + +export type ConfigurationBundleRef = z.infer; + +// ── Target-based variant configuration ───────────────────────────────────── + +export const TargetRefSchema = z.object({ + targetName: z.string().min(1).max(100), +}); + +export type TargetRef = z.infer; + +// ── Variant configuration union ──────────────────────────────────────────── +// Exactly one of configurationBundle or target must be set (XOR). + +const ConfigBundleVariantConfigSchema = z.object({ + configurationBundle: ConfigurationBundleRefSchema, + target: z.never().optional(), +}); + +const TargetVariantConfigSchema = z.object({ + configurationBundle: z.never().optional(), + target: TargetRefSchema, +}); + +export const VariantConfigurationSchema = z.union([ConfigBundleVariantConfigSchema, TargetVariantConfigSchema]); + +export type VariantConfiguration = z.infer; + +export const ABTestVariantSchema = z.object({ + name: VariantNameSchema, + weight: VariantWeightSchema, + variantConfiguration: VariantConfigurationSchema, +}); + +export type ABTestVariant = z.infer; + +// ── Evaluation config union ──────────────────────────────────────────────── + +export const PerVariantOnlineEvaluationConfigSchema = z.object({ + treatmentName: VariantNameSchema, + onlineEvaluationConfigArn: z.string().min(1), +}); + +export type PerVariantOnlineEvaluationConfig = z.infer; + +export const ABTestEvaluationConfigSchema = z.union([ + z.object({ onlineEvaluationConfigArn: z.string().min(1) }), + z.object({ + perVariantOnlineEvaluationConfig: z.array(PerVariantOnlineEvaluationConfigSchema).length(2), + }), +]); + +export type ABTestEvaluationConfig = z.infer; + +// ── Gateway filter ───────────────────────────────────────────────────────── + +export const GatewayFilterSchema = z.object({ + targetPaths: z.array(z.string().min(1).max(500)).max(1), +}); + +export type GatewayFilter = z.infer; + +// ── Traffic allocation ───────────────────────────────────────────────────── + +export const TrafficRouteOnHeaderSchema = z.object({ + headerName: z.string().min(1), +}); + +export const TrafficAllocationConfigSchema = z.object({ + routeOnHeader: TrafficRouteOnHeaderSchema, +}); + +export type TrafficAllocationConfig = z.infer; + +// ── AB Test schema ───────────────────────────────────────────────────────── + +export const ABTestSchema = z + .object({ + name: ABTestNameSchema, + description: ABTestDescriptionSchema, + mode: ABTestModeSchema, + gatewayRef: z.string().min(1), + roleArn: z.string().min(1).optional(), + variants: z.array(ABTestVariantSchema).length(2), + evaluationConfig: ABTestEvaluationConfigSchema, + gatewayFilter: GatewayFilterSchema.optional(), + trafficAllocationConfig: TrafficAllocationConfigSchema.optional(), + maxDurationDays: z.number().int().min(1).max(90).optional(), + enableOnCreate: z.boolean().optional(), + promoted: z.boolean().optional(), + }) + .refine( + data => { + const names = data.variants.map(v => v.name); + return names.includes('C') && names.includes('T1'); + }, + { message: 'Variants must include exactly one control (C) and one treatment (T1)', path: ['variants'] } + ) + .refine(data => data.variants.reduce((sum, v) => sum + v.weight, 0) === 100, { + message: 'Variant weights must sum to 100', + path: ['variants'], + }) + .refine( + data => { + if (data.mode === 'target-based') { + return data.variants.every(v => v.variantConfiguration.target != null); + } + return data.variants.every(v => v.variantConfiguration.configurationBundle != null); + }, + { + message: 'Target-based mode requires target on each variant; config-bundle mode requires configurationBundle', + path: ['variants'], + } + ); + +export type ABTest = z.infer; diff --git a/src/schema/schemas/primitives/config-bundle.ts b/src/schema/schemas/primitives/config-bundle.ts new file mode 100644 index 000000000..06702bd3c --- /dev/null +++ b/src/schema/schemas/primitives/config-bundle.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +// ============================================================================ +// Configuration Bundle Types +// ============================================================================ + +export const ConfigBundleNameSchema = z + .string() + .min(1, 'Name is required') + .max(100) + .regex( + /^[a-zA-Z][a-zA-Z0-9_]{0,99}$/, + 'Must begin with a letter and contain only alphanumeric characters and underscores (max 100 chars)' + ); + +export const ConfigBundleDescriptionSchema = z.string().min(1).max(500).optional(); + +/** Freeform configuration for a single component within a bundle. */ +export const ComponentConfigurationSchema = z.object({ + configuration: z.record(z.string(), z.unknown()), +}); + +export type ComponentConfiguration = z.infer; + +/** + * Map of component identifier (ARN or placeholder) to its configuration. + * + * Keys are typically resource ARNs (runtime ARN, gateway ARN) but may use + * placeholder tokens like `{{runtime:}}` when the bundle is created + * before deploy and ARNs are not yet available. + */ +export const ComponentConfigurationMapSchema = z.record(z.string(), ComponentConfigurationSchema); + +export type ComponentConfigurationMap = z.infer; + +export const ConfigBundleSchema = z.object({ + name: ConfigBundleNameSchema, + /** Discriminator required by the CDK package's schema validation. */ + type: z.literal('ConfigurationBundle').default('ConfigurationBundle'), + description: ConfigBundleDescriptionSchema, + /** Component configurations keyed by component ARN or placeholder. */ + components: ComponentConfigurationMapSchema, + /** Optional branch name for versioning. */ + branchName: z.string().max(128).optional(), + /** Optional commit message for this version. */ + commitMessage: z.string().max(500).optional(), +}); + +export type ConfigBundle = z.infer; diff --git a/src/schema/schemas/primitives/http-gateway.ts b/src/schema/schemas/primitives/http-gateway.ts new file mode 100644 index 000000000..4773b32e2 --- /dev/null +++ b/src/schema/schemas/primitives/http-gateway.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +// ============================================================================ +// HTTP Gateway Types +// ============================================================================ + +export const HttpGatewayNameSchema = z + .string() + .min(1, 'Name is required') + .regex( + /^[a-zA-Z][a-zA-Z0-9-]*$/, + 'Gateway name must start with a letter and contain only alphanumeric characters or hyphens (combined with project name must fit 48-char AWS limit)' + ); + +export const HttpGatewayTargetSchema = z.object({ + /** Gateway target name (referenced by AB test variants) */ + name: z.string().min(1).max(100), + /** Reference to a runtime name from spec.runtimes */ + runtimeRef: z.string().min(1), + /** Endpoint qualifier on the runtime (e.g., 'prod', 'staging'). Defaults to 'DEFAULT'. */ + qualifier: z.string().min(1).default('DEFAULT'), +}); + +export type HttpGatewayTarget = z.infer; + +export const HttpGatewaySchema = z + .object({ + /** Unique name for the HTTP gateway */ + name: HttpGatewayNameSchema, + /** Optional description */ + description: z.string().min(1).max(200).optional(), + /** Reference to a runtime name from spec.runtimes. One target is created per gateway pointing to this runtime. */ + runtimeRef: z.string().min(1), + /** IAM role ARN for gateway execution. Auto-created if omitted. */ + roleArn: z.string().min(1).optional(), + /** Additional targets for the gateway (for target-based AB testing). */ + targets: z.array(HttpGatewayTargetSchema).optional(), + }) + .strict(); + +export type HttpGateway = z.infer; diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index e14a0f248..a48985c84 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -1,3 +1,24 @@ +export type { + ABTest, + ABTestVariant, + ABTestEvaluationConfig, + ConfigurationBundleRef, + TrafficAllocationConfig, + VariantConfiguration, +} from './ab-test'; +export { + ABTestNameSchema, + ABTestDescriptionSchema, + ABTestSchema, + ABTestVariantSchema, + ABTestEvaluationConfigSchema, + ConfigurationBundleRefSchema, + TrafficAllocationConfigSchema, + VariantConfigurationSchema, + VariantNameSchema, + VariantWeightSchema, +} from './ab-test'; + export type { MemoryStrategy, MemoryStrategyType } from './memory'; export { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, @@ -44,3 +65,6 @@ export { PolicySchema, ValidationModeSchema, } from './policy'; + +export type { HttpGateway } from './http-gateway'; +export { HttpGatewayNameSchema, HttpGatewaySchema } from './http-gateway'; diff --git a/src/schema/schemas/primitives/online-eval-config.ts b/src/schema/schemas/primitives/online-eval-config.ts index 6dbc0787f..5b6f13cb6 100644 --- a/src/schema/schemas/primitives/online-eval-config.ts +++ b/src/schema/schemas/primitives/online-eval-config.ts @@ -18,6 +18,8 @@ export const OnlineEvalConfigSchema = z.object({ name: OnlineEvalConfigNameSchema, /** Agent name to monitor (must match a project agent) */ agent: z.string().min(1, 'Agent name is required'), + /** Optional runtime endpoint name to scope monitoring to a specific endpoint */ + endpoint: z.string().min(1).optional(), /** Evaluator names (custom), Builtin.* IDs, or evaluator ARNs */ evaluators: z.array(z.string().min(1)).min(1, 'At least one evaluator is required'), /** Sampling rate as a percentage (0.01 to 100) */ diff --git a/src/test-utils/cli-runner.ts b/src/test-utils/cli-runner.ts index 789624364..4526546c5 100644 --- a/src/test-utils/cli-runner.ts +++ b/src/test-utils/cli-runner.ts @@ -72,6 +72,14 @@ function getCLIPath(): string { * Run the AgentCore CLI via the local build (unit/integ tests). * Skips dependency installation by default for speed. */ -export async function runCLI(args: string[], cwd: string, skipInstall = true): Promise { - return spawnAndCollect('node', [getCLIPath(), ...args], cwd, skipInstall ? { AGENTCORE_SKIP_INSTALL: '1' } : {}); +export async function runCLI( + args: string[], + cwd: string, + options: { skipInstall?: boolean; env?: Record } = {} +): Promise { + const { skipInstall = true, env } = options; + return spawnAndCollect('node', [getCLIPath(), ...args], cwd, { + ...(skipInstall ? { AGENTCORE_SKIP_INSTALL: '1' } : {}), + ...env, + }); } diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts index ff127a35e..2920b822a 100644 --- a/src/test-utils/index.ts +++ b/src/test-utils/index.ts @@ -2,13 +2,33 @@ * Shared test utilities for AgentCore CLI tests. * Import these helpers instead of duplicating code in each test file. */ +import { runCLI as runCLIImpl } from './cli-runner.js'; +import { expect } from 'vitest'; export { runCLI, spawnAndCollect, cleanSpawnEnv, type RunResult } from './cli-runner.js'; +export { createTelemetryHelper, type TelemetryHelper, type TelemetryEntry } from './telemetry-helper.js'; export { exists } from './fs-helpers.js'; export { hasCommand, hasAwsCredentials, prereqs } from './prereqs.js'; export { createTestProject, type TestProject, type CreateTestProjectOptions } from './project-factory.js'; export { readProjectConfig } from './config-reader.js'; +export async function runSuccess(args: string[], cwd: string): Promise> { + const result = await runCLIImpl(args, cwd); + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', true); + return json as Record; +} + +export async function runFailure(args: string[], cwd: string): Promise> { + const result = await runCLIImpl(args, cwd); + expect(result.exitCode).toBe(1); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', false); + expect(json).toHaveProperty('error'); + return json as Record; +} + /** * Retry an async function up to `times` attempts with a delay between retries. */ diff --git a/src/test-utils/project-factory.ts b/src/test-utils/project-factory.ts index 77c77e1c6..61525f6c5 100644 --- a/src/test-utils/project-factory.ts +++ b/src/test-utils/project-factory.ts @@ -65,7 +65,7 @@ export async function createTestProject(options: CreateTestProjectOptions = {}): args.push('--json'); - const result = await runCLI(args, testDir, skipInstall); + const result = await runCLI(args, testDir, { skipInstall }); if (result.exitCode !== 0) { // Clean up on failure diff --git a/src/test-utils/telemetry-helper.ts b/src/test-utils/telemetry-helper.ts new file mode 100644 index 000000000..e7fa58949 --- /dev/null +++ b/src/test-utils/telemetry-helper.ts @@ -0,0 +1,56 @@ +import { globSync } from 'glob'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect } from 'vitest'; + +export interface TelemetryEntry { + value: number; + attrs: Record; +} + +export interface TelemetryHelper { + /** Temp directory used as AGENTCORE_CONFIG_DIR */ + dir: string; + /** Env vars to pass to runCLI to enable audit mode */ + env: { AGENTCORE_TELEMETRY_AUDIT: '1'; AGENTCORE_CONFIG_DIR: string }; + /** Read all JSONL entries from the audit telemetry directory */ + readEntries: () => TelemetryEntry[]; + /** Assert a metric was emitted with attrs matching the given subset */ + assertMetricEmitted: (expected: Record) => void; + /** Delete telemetry entries only (keeps the config dir) */ + clearEntries: () => void; + /** Delete the entire config directory — call in afterAll */ + destroy: () => void; +} + +export function createTelemetryHelper(): TelemetryHelper { + const dir = mkdtempSync(join(tmpdir(), 'agentcore-audit-')); + const helper: TelemetryHelper = { + dir, + env: { AGENTCORE_TELEMETRY_AUDIT: '1', AGENTCORE_CONFIG_DIR: dir }, + readEntries() { + return globSync(join(dir, 'telemetry', '*.json')).flatMap(f => + readFileSync(f, 'utf-8') + .trim() + .split('\n') + .map(line => JSON.parse(line) as TelemetryEntry) + ); + }, + assertMetricEmitted(expected) { + const entries = helper.readEntries(); + const match = entries.find(e => Object.entries(expected).every(([k, v]) => String(e.attrs[k]) === String(v))); + expect( + match, + `No telemetry entry matching ${JSON.stringify(expected)}\nFound ${entries.length} entries:\n${entries.map(e => JSON.stringify(e.attrs)).join('\n')}` + ).toBeDefined(); + }, + clearEntries() { + rmSync(join(dir, 'telemetry'), { recursive: true, force: true }); + }, + destroy() { + rmSync(dir, { recursive: true, force: true }); + }, + }; + return helper; +} From 2bbc259386641a919da03bedb0e1833e3ac13968 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 15:14:12 -0400 Subject: [PATCH 10/27] =?UTF-8?q?=F0=9F=94=80=20[Sync=20Conflict]=20Merge?= =?UTF-8?q?=20public/main=20=E2=86=92=20main=20(#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add GitHub Action for automated PR review via AgentCore Harness (#934) * feat: add GitHub Action for automated PR review via AgentCore Harness Adds a workflow that reviews PRs using Bedrock AgentCore Harness. The harness runs an AI agent in an isolated microVM with gh, git, and pre-cloned repos that fetches PR diffs and posts review comments. Workflow: - Triggers on PR open/reopen for agentcore-cli-devs team members - Supports manual workflow_dispatch for any PR URL - Adds/removes ai-reviewing label during review - Authenticates via GitHub OIDC to assume AWS role Files: - .github/workflows/pr-ai-review.yml — main workflow - .github/scripts/python/harness_review.py — harness invocation script - .github/scripts/python/harness_config.py — config from env vars - .github/scripts/models/ — local boto3 service model (InvokeHarness not yet in standard boto3) Required secrets: - HARNESS_AWS_ROLE_ARN — IAM role ARN for OIDC - HARNESS_ACCOUNT_ID — AWS account ID - HARNESS_ID — Harness ID * refactor: replace local service model with raw HTTP + SigV4 signing Eliminates the 220KB bundled service model by using direct HTTP requests with SigV4 authentication to invoke the harness endpoint. No extra dependencies needed — urllib3, SigV4Auth, and EventStreamBuffer are all part of botocore/boto3. Rejected: invoke_agent_runtime API | server rejects harness ARNs with ResourceNotFoundException Confidence: high Scope-risk: moderate * refactor: inline harness config into review script Remove separate harness_config.py — env vars are read directly in harness_review.py. One less file to maintain, config is still driven entirely by environment variables set in the GitHub workflow. * refactor: extract invoke_harness helper for cleaner main flow * refactor: simplify config and improve script readability - Replace HARNESS_ACCOUNT_ID + HARNESS_ID with single HARNESS_ARN env var - Extract prompts into separate .md files in .github/scripts/prompts/ - Extract stream parsing into print_stream() function - Add close_group() helper to deduplicate ::group:: bookkeeping * refactor: separate event parsing from display logic Extract parse_events() generator to handle binary stream decoding, keeping print_stream() focused on formatting and log groups. * docs: add explanatory comments to harness review functions * refactor: derive region from HARNESS_ARN instead of separate env var Eliminates HARNESS_REGION env var — the region is extracted from the ARN directly, so there's no risk of a mismatch causing confusing SigV4 auth errors. * chore: rename label to agentcore-harness-reviewing * refactor: move auth check to job level so entire review is skipped early Split into authorize + ai-review jobs. The ai-review job only runs if the PR author is authorized (team member or write access) or if triggered via workflow_dispatch. Removes repeated if conditions from every step. * chore: exclude AI prompt templates from prettier Prompt markdown files use intentional formatting that prettier would reflow, breaking the prompt structure. * fix: buffer streaming text to avoid per-token log lines in GitHub Actions (#946) Each text delta from the harness was printed individually with flush, creating a separate log line per token. Now text is buffered and flushed as complete lines at block boundaries. * fix: allow code-based evaluators in online eval configs (#947) * fix: allow code-based evaluators in online eval configs Remove restrictions that blocked code-based evaluators from being used in online evaluation configs. The service now supports code-based evaluators for online evaluation. Changes: - Remove code-based evaluator block in OnlineEvalConfigPrimitive - Remove code-based evaluator validation in schema superRefine - Remove code-based evaluator filter in TUI evaluator picker * style: fix prettier formatting * fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs (#949) * fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs When commands are invoked without flags in non-interactive environments (CI, piped stdin, agent automation), the CLI falls through to Ink TUI rendering which hangs indefinitely. Add a requireTTY() guard at every TUI entry point that checks process.stdout.isTTY and exits with a helpful error message directing users to --help for non-interactive flags. Closes #685 * fix: check both stdin and stdout isTTY in requireTTY guard The hang from #685 is caused by stdin not being a TTY (Ink reads keyboard input from stdin), not stdout. Check both stdin and stdout so the guard fires for piped stdin, redirected stdout, and CI environments where both are non-TTY. * fix: agentcore dev not working in windows (#951) * fix: use pull_request_target for fork PR support (#958) * fix: make label step non-blocking for fork PRs Fork PRs get read-only GITHUB_TOKEN regardless of workflow permissions, causing the addLabels API call to fail with 403. This crashed the entire job before the review could run. continue-on-error lets the review proceed even when labeling fails. * fix: use pull_request_target for full write access on fork PRs pull_request gives a read-only GITHUB_TOKEN for fork PRs, preventing labels and secrets from working. pull_request_target runs in the base repo context with full permissions. This is safe because we never check out or execute fork code — the harness fetches the PR diff via the GitHub API. * fix: lower eventExpiryDuration minimum from 7 to 3 days (closes #744) (#956) The AWS CreateMemory API allows a minimum of 3 days, but the CLI schema was rejecting values below 7. Update the Zod schema, LLM compacted types, import clamping logic, and all related tests. * fix: display session ID after CLI invoke completes (#957) * fix: display session ID after CLI invoke completes (closes #664) The streaming and non-streaming invoke responses include a session ID from the runtime, but the CLI paths discarded it. Now prints the session ID and a resume command hint after invoke output. * fix: include sessionId in AGUI protocol invoke result * test: add browser tests for agent inspector (#938) * feat: add telemetry schemas and client (#941) * chore: bump version to 0.11.0 (#967) Co-authored-by: github-actions[bot] * fix(invoke): auto-generate session ID for bearer-token invocations (#953) Closes #840 When invoking an agent with a bearer token (OAuth/CUSTOM_JWT) and no session ID, `AgentCoreMemoryConfig` raised a Pydantic validation error because `session_id=None` is rejected. Unlike SigV4 callers, bearer-token callers do not get a server-side auto-generated runtime session ID. Two-layer fix: 1. CLI synthesizes a UUID in `invoke` action when `--bearer-token` is set and `--session-id` is missing, using the existing `generateSessionId` helper. Covers both explicit `--bearer-token` and the CUSTOM_JWT auto-fetch path. 2. Strands memory session templates (http, agui, a2a) synthesize a UUID when `session_id` is falsy before constructing AgentCoreMemoryConfig. Protects direct runtime callers (curl, custom apps) who forget the `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` header. Snapshot tests updated. * fix: show 'Computing diff changes...' step during deploy diff phase (#952) The deploy TUI appeared frozen for 5-15 seconds between preflight completion and 'Publish assets' while cdkToolkitWrapper.diff() ran silently with no step marked as running. Add a dedicated pre-deploy diff step that transitions running -> success around the diff call so StepProgress always has something to highlight. Closes #781 * test: split browser tests into its own job, fix logs path (#975) * feat(invoke): add --prompt-file and stdin support for long prompts (#974) * feat(invoke): add --prompt-file and stdin support for long prompts Long prompts hit shell argument limits (E2BIG, typically 128KB-2MB) when passed as positional args. This adds two new sources: - --prompt-file : read prompt from a file - piped stdin: when no prompt is given and stdin is not a TTY, read the prompt from stdin Precedence is hybrid and backward-compatible: --prompt > positional > --prompt-file > stdin --prompt-file combined with piped stdin content returns an explicit collision error rather than silently picking one. Closes #686 * docs(invoke): document --prompt-file and stdin support * fix(import): remove experimental warning from import command (#977) The import feature has stabilized and no longer needs the experimental label. * fix: duplicate header flash and help menu truncation (closes #895, closes #637) (#955) - Return null during brief transitional phases to prevent Ink from rendering a header that gets immediately replaced by a different frame - Consolidate CreateScreen phases into a single Screen mount - Make help menu description width responsive to terminal size - Remove hardcoded 50-char description truncation limit * test: configure git in browser tests workflow (#976) * feat: add project-name option to create (#969) * Add project-name option to create * fix: address review feedback — restore name description and move backfill logic * ci: bump the github-actions group across 1 directory with 4 updates (#964) Bumps the github-actions group with 4 updates in the / directory: [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials), [actions/github-script](https://github.com/actions/github-script), [softprops/action-gh-release](https://github.com/softprops/action-gh-release) and [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action). Updates `aws-actions/configure-aws-credentials` from 5 to 6 - [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/v5...v6) Updates `actions/github-script` from 8 to 9 - [Commits](https://github.com/actions/github-script/compare/v8...v9) Updates `softprops/action-gh-release` from 2 to 3 - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) Updates `slackapi/slack-github-action` from 3.0.1 to 3.0.2 - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Changelog](https://github.com/slackapi/slack-github-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/slackapi/slack-github-action/compare/v3.0.1...v3.0.2) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: aws-actions/configure-aws-credentials dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: slackapi/slack-github-action dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump aws-cdk-lib (#962) Bumps the aws-cdk group with 1 update in the / directory: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib). Updates `aws-cdk-lib` from 2.248.0 to 2.250.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.250.0/packages/aws-cdk-lib) --- updated-dependencies: - dependency-name: aws-cdk-lib dependency-version: 2.250.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump postcss from 8.5.8 to 8.5.10 (#961) Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.10. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.10 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump secretlint from 11.4.1 to 12.2.0 (#916) Bumps [secretlint](https://github.com/secretlint/secretlint) from 11.4.1 to 12.2.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v11.4.1...v12.2.0) --- updated-dependencies: - dependency-name: secretlint dependency-version: 12.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @vitest/coverage-v8 from 4.1.2 to 4.1.5 (#915) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.2 to 4.1.5. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @secretlint/secretlint-rule-preset-recommend (#914) Bumps [@secretlint/secretlint-rule-preset-recommend](https://github.com/secretlint/secretlint) from 11.4.1 to 12.2.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v11.4.1...v12.2.0) --- updated-dependencies: - dependency-name: "@secretlint/secretlint-rule-preset-recommend" dependency-version: 12.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the aws-sdk group across 1 directory with 14 updates (#912) Bumps the aws-sdk group with 14 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@aws-sdk/client-application-signals](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-application-signals) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agentcore](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agentcore-control](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore-control) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cloudformation](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudformation) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cloudwatch-logs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudwatch-logs) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-resource-groups-tagging-api](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-resource-groups-tagging-api) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-xray](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-xray) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/credential-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/credential-providers) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cognito-identity-provider](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cognito-identity-provider) | `3.1036.0` | `3.1037.0` | Updates `@aws-sdk/client-application-signals` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-application-signals/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-application-signals) Updates `@aws-sdk/client-bedrock` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock) Updates `@aws-sdk/client-bedrock-agent` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agent) Updates `@aws-sdk/client-bedrock-agentcore` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agentcore) Updates `@aws-sdk/client-bedrock-agentcore-control` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore-control/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agentcore-control) Updates `@aws-sdk/client-bedrock-runtime` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-runtime) Updates `@aws-sdk/client-cloudformation` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudformation/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cloudformation) Updates `@aws-sdk/client-cloudwatch-logs` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudwatch-logs/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cloudwatch-logs) Updates `@aws-sdk/client-resource-groups-tagging-api` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-resource-groups-tagging-api/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-resource-groups-tagging-api) Updates `@aws-sdk/client-s3` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-s3) Updates `@aws-sdk/client-sts` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-sts) Updates `@aws-sdk/client-xray` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-xray/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-xray) Updates `@aws-sdk/credential-providers` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/credential-providers/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/packages/credential-providers) Updates `@aws-sdk/client-cognito-identity-provider` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cognito-identity-provider/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cognito-identity-provider) --- updated-dependencies: - dependency-name: "@aws-sdk/client-application-signals" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agent" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore-control" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-runtime" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudformation" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudwatch-logs" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cognito-identity-provider" dependency-version: 3.1034.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-resource-groups-tagging-api" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-xray" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/credential-providers" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump hono from 4.12.12 to 4.12.14 (#868) Bumps [hono](https://github.com/honojs/hono) from 4.12.12 to 4.12.14. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.12...v4.12.14) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.14 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump esbuild from 0.27.4 to 0.28.0 (#862) Bumps [esbuild](https://github.com/evanw/esbuild) from 0.27.4 to 0.28.0. - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.27.4...v0.28.0) --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.28.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * test: speed up CI and fix mock cleanup gaps (#989) * test: speed up CI and fix mock cleanup gaps - Node 20 only on PRs (full matrix on main) - 3-way vitest sharding for unit tests with blob report merging - Pre-bundle heavy deps (AWS SDK, Smithy, zod, commander) via deps.optimizer - Exclude tui-harness from unit test project (not production code) - Add afterEach(vi.restoreAllMocks) to 3 files with mock cleanup gaps - Move inline consoleSpy.mockRestore() to afterEach in logs-eval tests - Skip PTY tests when node-pty spawn is unavailable * style: fix prettier formatting in build-and-test.yml * fix: enable include-hidden-files for blob artifact upload upload-artifact@v7 defaults include-hidden-files to false, which skips the .vitest-reports directory. Also fail loudly if no files found. * feat: runtime endpoint support in AgentCore CLI (#979) * feat: add runtime endpoint support to AgentCore CLI - Schema: endpoints field on AgentEnvSpec, runtimeVersion in deployed state - Primitive: RuntimeEndpointPrimitive with add/remove/preview - TUI: Add and Remove flows with multi-field form - Status: endpoints nested under agents with deployment badges - Deploy: parseRuntimeEndpointOutputs + buildDeployedState pipeline * fix: correct output key prefix for runtime endpoint parsing The CFN output keys include the AgentEnvironment construct prefix (Agent{PascalName}) which was missing from the parser pattern. * fix: remove .omc state files and unused useCallback import - Remove .omc/ from git tracking, add to .gitignore - Remove unused useCallback import in AddRuntimeEndpointScreen.tsx * fix: shorten runtime endpoint description to prevent TUI overflow The description "Named endpoint (version alias) for a runtime" was too long and wrapped to the next line in the Add Resource menu. Shortened to "Named endpoint for a runtime". * fix: validate runtime endpoint version is a positive integer - Add explicit Number.isInteger check before schema validation - Change Commander parser from parseInt to Number so floats like 3.5 are caught instead of silently truncated * fix: use agent/endpoint composite key to prevent React key collision Endpoint names can collide across runtimes (e.g., both have "prod"). Changed React key from epName to agent.name/epName to prevent duplicate key warnings that pollute the TUI viewport. * fix: render runtime endpoints in status --type runtime-endpoint When filtering by --type runtime-endpoint, agents array is empty so the agents section (which nests endpoints) never renders. Added a standalone Runtime Endpoints section that shows when endpoints exist but agents don't (i.e., when type-filtering). * fix: add runtime-endpoint to status --help --type documentation The --type option help text was missing runtime-endpoint from the list of valid resource types. * fix: return richer JSON response from add runtime-endpoint add now returns { success, endpointName, agent, version } instead of sparse { success: true }, matching the richer response shape from remove runtime-endpoint. * fix: validate endpoint version against deployed runtime version - TUI: show "Current deployed version: N" and valid range (1-N) - TUI: reject version exceeding latest deployed version - CLI: check deployed-state.json for max version, reject if exceeded - If runtime not deployed, only positive integer check applies * chore: remove planning and bug bash docs from PR * fix: use composite key and parentName for endpoint identification - Add parentName field to ResourceStatusEntry for structured parent linking - Use runtimeName/endpointName composite key in remove/preview/getRemovable - Status command filters endpoints by parentName instead of parsing detail string - React keys use structured parentName/name instead of display strings * test: add comprehensive unit tests for RuntimeEndpointPrimitive 23 tests covering add(), remove(), previewRemove(), getRemovable(): - Runtime lookup, duplicate detection, version validation - Composite key removal targeting correct runtime - Empty endpoints dict cleanup - Version validation against deployed state - Richer JSON response shape * fix: remove dead findGatewayTargetReferences stub * fix: use BasePrimitive configIO instead of ad-hoc ConfigIO in add() * fix: use Number() instead of parseInt in TUI version validation * chore: fix prettier formatting * fix: use T[] instead of Array to satisfy eslint array-type rule * feat: add gateway import command with executionRoleArn support (#855) * feat: add gateway import command and unhide import from TUI Add `agentcore import gateway --arn ` to import existing AWS gateways (with all targets) into a local CLI project. Also remove import from the HIDDEN_FROM_TUI list so it appears in the interactive TUI. - Add AWS SDK wrappers for gateway/target list/get APIs - Add import-gateway.ts with multi-resource CFN import support - Add resourceName schema field to preserve actual AWS gateway name during import - Register gateway in TUI ImportSelectScreen and ImportProgressScreen - Extend ARN pattern, deployed state, and CFN constants for gateway type * fix: expand ARN input to show full resource ARN and add gateway support The ARN text input was truncating long ARNs. Use the expandable prop to wrap text across multiple lines. Also add gateway to the ARN validation pattern and resource type labels. * refactor: remove --name and --yes flags from import gateway command Remove --name (confusing local rename) and --yes (no prompts to confirm) from the gateway import command. The gateway's AWS name is used directly. * feat: add e2e tests for import gateway command Add end-to-end tests that create a real AWS gateway with an MCP server target, import it via `agentcore import gateway --arn`, and verify the resulting agentcore.json fields and deployed-state.json entries. New files: - e2e-tests/fixtures/import/setup_gateway.py: creates gateway + target - e2e-tests/fixtures/import/common.py: gateway wait helpers - e2e-tests/fixtures/import/cleanup_resources.py: gateway cleanup Constraint: Tests follow the existing import-resources.test.ts pattern Confidence: high Scope-risk: narrow * chore: gitignore bugbash-resources.json and .omc/ * feat: preserve gateway executionRoleArn during import Extract roleArn from the AWS GetGateway response and map it to executionRoleArn in agentcore.json. On deploy, CDK uses iam.Role.fromRoleArn() instead of creating a new role, keeping the original permissions intact. Constraint: imported roles use mutable: false so CDK cannot modify them Rejected: always create new role | breaks permissions on re-import Confidence: high Scope-risk: narrow * refactor: export internal gateway import functions for unit testing Add @internal exports for toGatewayTargetSpec, resolveOutboundAuth, toGatewaySpec, and buildCredentialArnMap to enable direct unit testing of the pure mapping functions in import-gateway.ts. Confidence: high Scope-risk: narrow * test: add unit tests for mcpServer target mapping and credential resolution Bugbash coverage for toGatewayTargetSpec and resolveOutboundAuth: - mcpServer with no auth, OAuth, and API_KEY credentials - Credential resolution warnings when ARNs not in project - Targets with no MCP configuration - OAuth scopes pass-through and empty scopes omission 8 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for apiGateway, openApiSchema, smithyModel, lambda target mapping Bugbash coverage for toGatewayTargetSpec non-mcpServer target types: - apiGateway: restApiId, stage, toolFilters, toolOverrides mapping - openApiSchema: S3 URI mapping, missing URI warning - smithyModel: S3 URI mapping, missing URI warning - lambda: S3 tool schema to lambdaFunctionArn mapping, missing ARN, inline-only schema warning, progress messages - Unrecognized target type warning 13 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for toGatewaySpec gateway-level field mapping Bugbash coverage for toGatewaySpec AWS-to-CLI schema mapping: - Authorizer types: NONE, AWS_IAM, CUSTOM_JWT with all JWT fields - CUSTOM_JWT customClaims with full claim structure - Semantic search: SEMANTIC/KEYWORD/missing protocolConfiguration - Exception level: DEBUG/undefined/other values - Policy engine: ARN name extraction, mode preservation - Optional fields: resourceName, description, tags, executionRoleArn - Edge cases: empty tags object omitted, empty JWT arrays omitted 23 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for handleImportGateway full flow validation Bugbash coverage for the main gateway import flow: - Happy path: successful import with --arn, config written, result verified - Rollback: pipeline failure restores original config, noResources error - Duplicate detection: name collision, resource ID already tracked - Name validation: invalid name regex, --name override preserves resourceName - Auto-select: single gateway auto-selected, multiple gateways error, no gateways error - Target mapping: skipped targets warning, non-READY gateway continues 12 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for buildCredentialArnMap and CFN template matching Bugbash coverage for credential resolution and CFN resource matching: - buildCredentialArnMap: reads ARN-to-name map from deployed state, handles multiple credentials, empty/missing state, thrown errors - findLogicalIdByProperty: gateway by Name property, resourceName fallback, target by Name, Fn::Join/Fn::Sub intrinsic function patterns, regex boundary check prevents false substring matches - findLogicalIdsByType: single gateway fallback, single target fallback, multiple targets prevent fallback 14 tests, all passing. Confidence: high Scope-risk: narrow * fix: exclude already-deployed logical IDs when building import resource list When a project already contains an imported resource (gateway + target, agent, memory, etc.), a subsequent import of a different resource that shares a Name with the deployed one caused buildResourcesToImport to resolve the OLD logical ID via findLogicalIdByProperty. The resulting CFN change set then failed with "Resources [...] passed in ResourceToImport are already in a stack and cannot be imported." Thread the deployed template into every buildResourcesToImport callback and skip logical IDs already present in the stack during both the name lookup and the single-candidate fallback. Constraint: GatewayTarget has no structural parent ref in Properties — only the physical-ID tuple (GatewayIdentifier, TargetId), so scoping the synth search by parent gateway is not available. Rejected: Parse Fn::Ref/Fn::GetAtt from GatewayIdentifier | brittle intrinsic traversal Rejected: Match by physical TargetId | synth template has no physical ID for new resources Rejected: Strip deployed resources from synth before lookup | breaks buildImportTemplate Confidence: high Scope-risk: narrow Directive: new callbacks into executeCdkImportPipeline must accept and honor the deployedTemplate arg Not-tested: multi-region / cross-stack-identifier collisions * fix(import): translate AccessDenied on GetGateway to a friendly not-found error When importing a gateway by a well-formed but nonexistent ARN, the BedrockAgentCore control plane returns AccessDenied (not ResourceNotFound) for bedrock-agentcore:GetGateway. The CLI surfaced the raw SDK error — which is misleading when the caller has full Admin access and the gateway simply doesn't exist. Catch AccessDenied from getGatewayDetail and return a targeted failure with guidance: the gateway is likely nonexistent / the ARN is malformed / the caller lacks GetGateway. Point the user at list-gateways so they can confirm. Constraint: AWS returns AccessDenied instead of ResourceNotFound for nonexistent gateway IDs; we cannot distinguish the two server-side Rejected: Client-side ARN existence probe via ListGateways | extra latency on the happy path and still racy Confidence: high Scope-risk: narrow Directive: Do not swallow other error classes here — only AccessDenied is reinterpreted * fix(import): detect AWS_REGION / ARN region mismatch before import Previously when a user ran import with AWS_REGION=us-west-2 against a us-east-1 ARN, and no deployment targets existed yet, the CLI silently synthesized a default target from the ARN's region and proceeded — so the user would unknowingly import from a different region than they intended, leaving agentcore.json pointed at the wrong region and causing cross-region CFN errors on later deploy. Short-circuit resolveImportTarget when AWS_REGION (or AWS_DEFAULT_REGION) is set and disagrees with the ARN's region, and ask the user to reconcile explicitly. Constraint: Must fail fast before any side effects (writing aws-targets.json, calling GetGateway) Rejected: Warn-and-continue | a silent cross-region import is exactly the failure mode we're preventing Confidence: high Scope-risk: narrow Directive: Only throw when both env region AND ARN region are present — do not require AWS_REGION to be set * fix(import): allow re-import of resource after remove without CDK pipeline After `agentcore remove gateway`, the gateway entry remains in deployed-state.json (correctly, since CFN still manages it) but is removed from agentcore.json. A subsequent `agentcore import gateway` would reject with "already imported" because the dedup check only looked at deployed-state. Now, when a resource exists in deployed-state but not in agentcore.json, the import skips the CDK pipeline and just re-adds the resource to agentcore.json. Applies to both the gateway-specific and generic import orchestrators. * style: fix prettier formatting for import-utils and ArnInputScreen * fix(import): address PR review blockers B4, B6, B7, H2, H5, H7, H8 - B4: Hard-fail when credential provider ARN is not found in deployed state instead of silently dropping outboundAuth - B7: Preserve outboundAuth on lambda→lambdaFunctionArn mapping and allow OAUTH/NONE auth types for lambdaFunctionArn targets - H2: Remove re-import fast path; run full CDK pipeline so out-of-band targets are properly imported. Treat noResources as success for re-imports since all resources are already in the CFN stack - H5: Replace hardcoded arn:aws: with partition-agnostic arn:[^:]+: in ARN validation and region extraction regexes - H7/H8: Add regex validation and max length for executionRoleArn, use GatewayNameSchema for resourceName, add refine ensuring both fields are set together or both omitted * fix(import): remove credential ARN from error messages to resolve CodeQL alert CodeQL flagged clear-text logging of credential provider ARNs. The target name provides sufficient context for the user to identify the issue. * fix(import): remove resourceName/executionRoleArn co-variance refine (#996) The refine required both fields to be set together, but resourceName is always needed on import (to preserve the AWS name) while executionRoleArn is only present when the gateway has a custom role. Gateways without a custom role (service auto-creates one) fail the refine because resourceName is set but executionRoleArn is not. Keep the individual field validations (GatewayNameSchema for resourceName, regex for executionRoleArn). * fix(e2e): separate gateway import test and add PR-changed test detection (#999) Split gateway import e2e tests into their own file so they can run independently with faster setup (only setup_gateway.py instead of all 4 resource scripts). Update the PR e2e workflow to detect changed test files and include them alongside the strands-bedrock baseline, using only the main CDK source to reduce CI time. Constraint: PR workflow must always run strands-bedrock as a baseline Rejected: Keep gateway in combined suite | setup creates unnecessary resources when running gateway-only Confidence: high Scope-risk: narrow * fix(e2e): add debug logging for gateway import CI failures (#1001) * fix(e2e): add debug logging for gateway import failures Print the import log file and CloudFormation stack events when the gateway import test fails to help diagnose IMPORT_ROLLBACK_IN_PROGRESS errors in CI. Confidence: high Scope-risk: narrow * fix(e2e): add shared debug logging for all import test failures Add dumpImportDebugInfo to e2e-helper that prints the import log file and CloudFormation stack events when an import fails. Used by both import-resources and import-gateway tests to diagnose CI failures. Confidence: high Scope-risk: narrow * chore: bump version to 0.12.0 (#1002) Co-authored-by: github-actions[bot] * test: remove 44 render-only and framework-testing tests (#998) * 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. * 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 * feat: add CloudWatch traces API for web UI (#997) * fix: remove CONFIG_DIR exclusion from zip stage to preserve dependency agentcore/ packages (#1015) PR #844 correctly removed the flat name-based agentcore exclusion and threaded rootDir through copySourceTree, but the same CONFIG_DIR check remained in collectFiles/collectFilesSync (the zip stage). Since the zip stage operates on the staging directory — not the project root — the check incorrectly stripped any top-level agentcore/ Python package installed by uv (e.g., langgraph_checkpoint_aws/agentcore/) from the deployment artifact, causing ModuleNotFoundError at runtime. The CONFIG_DIR exclusion is only needed in copySourceTree (which copies from the project root into staging). By the time we zip, the project config dir was already filtered out — the only agentcore/ in staging is a legitimate dependency package. Closes #843 * ci: add coordinated main + preview release workflow (#995) * ci: add coordinated main + preview release workflow Adds a single workflow_dispatch that releases both branches together, ensuring they stay in sync on npm. * fix: address review — bump script compat, pre-publish verification, drop unused artifacts - Preview bump now uses `prerelease --prerelease-tag preview` which the bump-version.ts script actually accepts - Added verify-merges job that checks both main and preview have the expected versions before either publish runs (prevents drift) - Both publish jobs now depend on verify-merges instead of each other, so neither publishes unless both PRs are confirmed merged - Removed upload-artifact steps from test jobs since publish jobs rebuild from source post-merge * fix: auto-rebase preview onto main in preflight step Instead of failing when preview isn't rebased, the workflow now rebases automatically. If there are conflicts, it aborts and directs the user to resolve manually. * ci: add sync-preview workflow, simplify release preflight - New sync-preview.yml: runs on every push to main, auto-rebases preview onto main. Silently skips on conflicts (no failure). - Release workflow preflight reverted to a simple check — relies on sync-preview having already done the rebase. * fix: use merge instead of rebase for preview sync Rebase overwrites preview-specific values (package version, tests). Merge preserves preview's divergent files and only conflicts when both branches touch the same lines. * fix: concurrency control, conflict notifications, CDK tag TODO - Add concurrency group to sync-preview to prevent parallel race - On merge conflict, auto-create a GitHub issue (deduplicated) instead of silently skipping - Add TODO comment for CDK preview dist-tag in prepare-preview * fix: create PR with conflict markers instead of issue on merge conflict On conflict, sync-preview now: - Creates a branch with the merge conflict markers committed - Opens a PR targeting preview with resolution instructions - Tags the original commit author for visibility - Deduplicates (skips if a sync PR is already open) * chore(deps): bump the aws-sdk group with 14 updates (#1024) Bumps the aws-sdk group with 14 updates: | Package | From | To | | --- | --- | --- | | [@aws-sdk/client-application-signals](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-application-signals) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agentcore](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agentcore-control](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore-control) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cloudformation](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudformation) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cloudwatch-logs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudwatch-logs) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-resource-groups-tagging-api](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-resource-groups-tagging-api) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-xray](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-xray) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/credential-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/credential-providers) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cognito-identity-provider](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cognito-identity-provider) | `3.1037.0` | `3.1038.0` | Updates `@aws-sdk/client-application-signals` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-application-signals/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-application-signals) Updates `@aws-sdk/client-bedrock` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock) Updates `@aws-sdk/client-bedrock-agent` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agent) Updates `@aws-sdk/client-bedrock-agentcore` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agentcore) Updates `@aws-sdk/client-bedrock-agentcore-control` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore-control/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agentcore-control) Updates `@aws-sdk/client-bedrock-runtime` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-runtime) Updates `@aws-sdk/client-cloudformation` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudformation/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cloudformation) Updates `@aws-sdk/client-cloudwatch-logs` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudwatch-logs/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cloudwatch-logs) Updates `@aws-sdk/client-resource-groups-tagging-api` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-resource-groups-tagging-api/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-resource-groups-tagging-api) Updates `@aws-sdk/client-s3` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-s3) Updates `@aws-sdk/client-sts` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-sts) Updates `@aws-sdk/client-xray` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-xray/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-xray) Updates `@aws-sdk/credential-providers` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/credential-providers/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/packages/credential-providers) Updates `@aws-sdk/client-cognito-identity-provider` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cognito-identity-provider/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cognito-identity-provider) --- updated-dependencies: - dependency-name: "@aws-sdk/client-application-signals" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agent" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore-control" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-runtime" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudformation" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudwatch-logs" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-resource-groups-tagging-api" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-xray" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/credential-providers" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cognito-identity-provider" dependency-version: 3.1038.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-sdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the aws-cdk group with 2 updates (#1025) Bumps the aws-cdk group with 2 updates: [@aws-cdk/toolkit-lib](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/@aws-cdk/toolkit-lib) and [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib). Updates `@aws-cdk/toolkit-lib` from 1.24.0 to 1.25.0 - [Release notes](https://github.com/aws/aws-cdk-cli/releases) - [Commits](https://github.com/aws/aws-cdk-cli/commits/@aws-cdk/toolkit-lib@v1.25.0/packages/@aws-cdk/toolkit-lib) Updates `aws-cdk-lib` from 2.250.0 to 2.251.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.251.0/packages/aws-cdk-lib) --- updated-dependencies: - dependency-name: "@aws-cdk/toolkit-lib" dependency-version: 1.25.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-cdk - dependency-name: aws-cdk-lib dependency-version: 2.251.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @opentelemetry/resources from 2.6.1 to 2.7.0 (#1026) Bumps [@opentelemetry/resources](https://github.com/open-telemetry/opentelemetry-js) from 2.6.1 to 2.7.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/v2.6.1...v2.7.0) --- updated-dependencies: - dependency-name: "@opentelemetry/resources" dependency-version: 2.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @secretlint/secretlint-rule-preset-recommend (#1028) Bumps [@secretlint/secretlint-rule-preset-recommend](https://github.com/secretlint/secretlint) from 12.2.0 to 12.3.1. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v12.2.0...v12.3.1) --- updated-dependencies: - dependency-name: "@secretlint/secretlint-rule-preset-recommend" dependency-version: 12.3.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump secretlint from 12.2.0 to 12.3.1 (#1029) Bumps [secretlint](https://github.com/secretlint/secretlint) from 12.2.0 to 12.3.1. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v12.2.0...v12.3.1) --- updated-dependencies: - dependency-name: secretlint dependency-version: 12.3.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @opentelemetry/sdk-metrics from 2.6.1 to 2.7.0 (#1030) Bumps [@opentelemetry/sdk-metrics](https://github.com/open-telemetry/opentelemetry-js) from 2.6.1 to 2.7.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/v2.6.1...v2.7.0) --- updated-dependencies: - dependency-name: "@opentelemetry/sdk-metrics" dependency-version: 2.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(ci): update snapshots after CDK version sync in release workflow (#1033) The release workflow syncs @aws/agentcore-cdk to the latest npm version in the asset template, but never updates the snapshot file. This causes the asset snapshot test to fail because the snapshot still holds the old version string. * fix(ci): enable coverage collection in sharded unit test runs (#1034) The coverage report on PRs was empty (0/0 Unknown%) because the sharded unit-test jobs ran without --coverage. Without that flag, V8 coverage data is never collected, so the blob reports contain no coverage maps. The merge-reports step then merges undefined entries and produces empty results. Also fixes the coverage report action referencing a nonexistent vitest.unit.config.ts (should be vitest.config.ts). * fix(ci): move snapshot update after build in release workflow (#1036) Move `npm run test:update-snapshots` from inside the CDK sync step (before build) to its own step after `npm run build`. The test suite needs built artifacts to pass — running it before the build caused 18 test failures. * fix(ci): install uv in release workflow prepare steps (#1038) The create.test.ts tests require uv for Python project scaffolding. The Build and Test workflow installs it via astral-sh/setup-uv, but the release workflow's prepare steps were missing it, causing test failures during the snapshot update step. * chore: bump version to 0.12.1 * fix: remove CDK version auto-sync from release workflow and restore caret range (#1044) Remove the auto-sync step that bumped @aws/agentcore-cdk during releases — version updates will be managed manually via PRs. Restore the caret range (^0.1.0-alpha.19) in the asset and snapshot that was dropped by the auto-sync in #1042. * fix: add Accept header to HTTP protocol invocation proxy (#1051) The dev web UI proxy for HTTP protocol agents doesn't include an Accept header when forwarding requests to /invocations. The bedrock-agentcore runtime SDK checks for Accept: text/event-stream before enabling SSE streaming (via @fastify/sse reply.sse), so streaming handlers always get a 406 response in the inspector. A2A and AGUI protocol paths already send the header correctly — this brings HTTP in line with them. Using "text/event-stream, */*" so streaming agents get SSE enabled while non-streaming agents can respond in whatever format they need. * feat: add telemetry audit mode with FileSystemSink (#1014) * feat: add FilesystemSink for telemetry audit mode * feat: instrument help.modes with telemetry, add audit integ test * refactor: move harness resources to .github/harness/ (#992) * refactor: move harness resources to .github/harness/ Move PR reviewer harness files into a dedicated .github/harness/ directory, separate from the general .github/scripts/ used by Strands workflows. - Move harness_review.py, prompts/ to .github/harness/ - Add Dockerfile for the harness container (dual-token: CLONE_TOKEN for git clones, GITHUB_TOKEN for gh CLI/PR comments) - Add README documenting the harness directory - Update pr-ai-review workflow to reference new path - Update .prettierignore for new prompts location * fix(harness): update Dockerfile comment to accurately describe token handling Tokens are baked into image layers at build time — the previous comment incorrectly implied they were not stored. Updated to make the security posture explicit: the image itself must be treated as a secret. * refactor(harness): use boto3 invoke_harness instead of raw SigV4 HTTP Replace manual SigV4 signing + urllib3 + EventStreamBuffer parsing with the native boto3 bedrock-agentcore client's invoke_harness method. This simplifies the code significantly and leverages the typed event stream response from the SDK. Rejected: keep raw HTTP approach | boto3 now supports invoke_harness natively Confidence: high Scope-risk: narrow Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * Revert "refactor: move harness resources to .github/harness/ (#992)" This reverts commit aef3890e460a9a06db7f8465a157588bc4b0f7b3. * refactor: move harness resources to .github/harness/ and use boto3 invoke_harness - Move harness_review.py, prompts/ to .github/harness/ - Add Dockerfile for the harness container (dual-token setup) - Add README documenting the harness directory - Update pr-ai-review workflow to reference new path - Replace manual SigV4 signing + urllib3 with native boto3 invoke_harness - Update .prettierignore for new prompts location * feat: update @aws/agent-inspector to 0.3.0 * fix(harness): add error handling for invoke_harness API call (#1056) Wrap the invoke_harness_streaming call in a try/except so boto3 errors (bad credentials, network issues, invalid ARN) produce a clean error message instead of a raw traceback in GitHub Actions logs. * chore: bump version to 0.12.2 Co-authored-by: github-actions[bot] * ci: cut full e2e time in half via vitest sharding (#1016) * ci: shard e2e full suite across 6 runners - Add 6-way vitest sharding to the cdk-source matrix (2 → 12 parallel runners) - Isolate import test resources per run via RESOURCE_SUFFIX to prevent concurrent conflicts * fix: import RESOURCE_SUFFIX in cleanup_resources.py * refactor: consolidate cli-config into global-config (#802) * feat: make parsing resilient to individual failures (#1062) * fix: forward custom headers in bearer token invoke paths (#1065) Custom headers passed via -H/--header were silently dropped when using CUSTOM_JWT auth because invokeWithBearerToken and invokeWithBearerTokenStreaming did not merge options.headers into the request headers. Extract buildBearerInvokeHeaders() (paralleling the existing buildMcpBearerHeaders) and use it in both invoke paths. Closes #1052 * feat: wire telemetry into all add.* commands (#1050) * feat: wire telemetry withCommandRun into all add.* commands * refactor: extract cliCommandRun helper, apply to all add.* primitives * test: add audit file assertions for all add.* telemetry * test: add telemetry audit assertions to existing add integ tests * refactor: extract shared audit test utils into src/test-utils/audit.ts * fix: address review feedback — guard telemetry init, replaceAll, unknown fallback, TUI try/catch * fix: AgentPrimitive TUI try/catch, standardize uses safeParse * refactor: extract standalone assertTelemetry helper * refactor: rename audit.ts to telemetry-helper.ts, clarify method names * refactor: move assertTelemetry into TelemetryHelper as assertMetricEmitted * feat: add telemetry to TUI add paths via withAddTelemetry * fix: review feedback — withAddTelemetry safety, standardize handles undefined, MCP agent attrs, policy TUI attrs * fix: remove unnecessary type assertion * fix: address review — document standardize cast, add policy-engine + episodic telemetry tests * refactor: centralize gateway target type mapping in common-shapes * fix: preserve original function error with telemetry wrapper * refactor: extract telemetryAttrs into a single line * feat: wire up telemetry for addAgent * fix: resolve e2e import test concurrency races (#1067) * fix: resolve e2e import test concurrency races Fix two independent concurrency issues causing flaky e2e import tests: 1. TOCTOU race in evaluator import (import-evaluator.ts): The beforeConfigWrite hook lists all online eval configs then fetches details for each with Promise.all. If a config is deleted between the list and get calls, the API throws 'Online evaluation configuration not found' and the entire import fails. Fixed by using Promise.allSettled and filtering out disappeared configs. 2. Resource name collisions across parallel CI shards (setup_*.py): Python setup scripts generated resource names using int(time.time()) (second-level precision). Parallel CI shards starting in the same second would collide with ConflictException. The test already passes a unique RESOURCE_SUFFIX env var but scripts ignored it for naming. Added NAME_SUFFIX to common.py that prefers RESOURCE_SUFFIX when set, and updated all setup scripts to use it. * chore: remove unused time imports from setup scripts * feat: evo preview features — config bundles, batch evaluation, recommendations, AB testing (#1068) * feat: add sync workflow * fix: formatting * fix: only sync to main branch * fix: codeql permissions * feat: add configuration bundle support Add ConfigBundle as a new resource type with full lifecycle: - Schema: ConfigBundleSchema with name validation, component configurations - Primitive: ConfigBundlePrimitive for add/remove operations - API client: SigV4-signed HTTP requests for config bundle CRUD operations - Deploy: post-deploy hook to sync config bundles with control plane - Status: config-bundle resource type in status command - TUI: add wizard (name, description, components, branch, commit message), remove flow, ResourceGraph integration - State: carry forward configBundles across redeploys in buildDeployedState * fix: use correct SigV4 service name for config bundle API The signing service must be 'bedrock-agentcore' for all stages, not 'bedrock-agentcore-control' for prod. The endpoint hostname differs from the signing service name. * fix: config bundle deploy and TUI defaults - Add config bundle post-deploy setup to TUI deploy flow (useDeployFlow) - Add clientToken to config bundle update API call - Add parentVersionIds on update (required by API) - Default branchName to "main" and commitMessage when not specified - Add placeholders for branch/message in TUI wizard - Fallback to find-by-name or create when update fails (stale IDs) - Remove debug logging from actions.ts * fix: use nullish coalescing for branchName default * feat: add edit config-bundle command with deploy diff check - Add `agentcore edit config-bundle` CLI command with --bundle, --components, --components-file, --description, --branch, --message, --json flags - Add interactive TUI wizard for editing config bundles (select bundle, input method, components, commit message, branch name, confirm) - Add diff check to post-deploy: skip API update when components and description are unchanged, avoiding unnecessary version creation - Use getConfigurationBundleVersion instead of getConfigurationBundle to avoid branch-not-found errors on bundles created with different branches - Align default branch name to 'mainline' (API default) instead of 'main' - For updates, inherit branch from current API state when not specified * test: add unit tests for edit config-bundle and deploy diff check - post-deploy-config-bundles: 13 tests covering create, update, skip (diff check), delete, branch inheritance, fallback paths, errors - ConfigBundlePrimitive.edit: 7 tests covering component updates, optional field handling, missing bundle errors, field preservation - useEditConfigBundleWizard: 16 tests covering step navigation, setters, goBack, reset, currentIndex tracking, step labels * fix: address review comments * fix: remove duplicate config-bundle subcommand from edit command * feat: config bundle version history CLI + TUI (#46) * chore: remove edit config-bundle command Users should edit agentcore.json directly to update config bundles. Removes the edit CLI command, TUI screens, wizard hooks, and tests. * feat: add config-bundle CLI commands for version history Adds `agentcore config-bundle` with three subcommands: - `versions` — list version history grouped by branch - `get-version` — view specific version details and components - `diff` — client-side deep diff between two versions Also adds filter support (branchName, latestPerBranch, createdBy) to the listConfigurationBundleVersions API client. * feat: add config bundle hub TUI screens Add TUI screens for browsing config bundles, viewing version history with branch grouping, version detail drill-down, and diff comparison between versions. * fix: resolve config bundle versionId when falling back to list API (#49) The Recommendation API requires versionId to be non-null when using configurationBundle input. When resolveBundleByName fell back to the list API (bundle not in deployed state), it returned no versionId, causing a 400 validation error. Now calls getConfigurationBundle after list to fetch the latest versionId. Also adds versionId to the ResolvedBundle interface and returns it from the deployed-state fast path. * chore: remove get-version subcommand from config-bundle CLI The versions --json and diff commands cover all practical use cases. Keeps the command surface lean: versions + diff only. * feat: add Recommendations API, TUI wizard, and CLI commands (#45) * feat: add Recommendation API wrappers, CLI commands, and operations layer Implement the Recommendations/Optimization feature for AgentCore CLI: - SigV4-signed HTTP client for Start/Get/List/Delete Recommendation (DP) - Operations layer with orchestration, polling, and local storage - CLI commands: evals recommend, evals recommendation history/delete, run promote - 27 unit tests covering API, storage, and orchestration logic - Live-validated field names and ARN formats against prod API * feat: add recommendation TUI wizard with session discovery and multi-evaluator support - Add full recommendation wizard TUI (type, agent, evaluators, input, trace source, sessions, confirm) - Add session discovery flow: discover sessions from CloudWatch, multi-select specific sessions - Support both CloudWatch logs and session ID trace sources - Pass selected sessionIds to recommendation API cloudwatchLogs config - Add request ID capture and error detail extraction for debugging FAILED recommendations - Fix recommendation API test mocks (add headers for requestId capture) - Add scrollable list support (maxVisibleItems) to MultiSelectList, SelectList, WizardSelect - Wire recommendation screen into App.tsx and EvalHubScreen navigation * feat: add session span fetching, recommendation tests, and TUI integration - Add fetch-session-spans module for retrieving OTEL spans from aws/spans and log records from runtime log groups with session ID filtering - Add comprehensive tests for fetch-session-spans (9 tests) and extend run-recommendation tests (12 new tests covering file input, spans-file trace source, tool-desc auto-fetch, error handling, ARN passthrough) - Wire recommendation hub, history screen, and list/delete CLI commands - Update TUI routing for recommendation flows from eval and run hubs - Add recommendation constants (poll intervals, terminal statuses) * chore: remove list commands and promote stub, fix agents→runtimes rename Remove `agentcore list recommendations` and `agentcore list recommendation --id` commands (top-level `list` command deleted entirely). Remove `run promote` stub. Fix typecheck errors from agents→runtimes schema rename in recommendation files. * feat: batch evaluation — stateless eval API, TUI wizard, local storage (#26) * feat: add EvaluationJob resource — schema, primitive, deploy hook, TUI, and tests Phase 1 of EvalJobRunner: CRUD + deploy integration for the EvaluationJob control plane resource. - Schema: EvaluationJobSchema in agentcore.json, deployed state tracking - Primitive: EvaluationJobPrimitive with add/remove lifecycle - AWS client: SigV4-signed HTTP wrappers for EvalJob CP operations - Deploy: post-deploy hook creates/updates/deletes eval jobs imperatively - CFN outputs: parse eval job execution role ARN from stack outputs - TUI: add evaluation-job wizard flow + remove flow integration - Tests: 53 tests across schema, primitive, AWS client, deploy hook, and TUI * feat: add `run evaluation-job` command with DP API wrappers and orchestration - Data plane API wrappers (RunEvaluationJob, GetEvaluationJobRun, ListEvaluationJobRuns) with SigV4 signing against bedrock-agentcore service - Orchestration: resolve job from deployed state, generate runId, start run, poll for completion, fetch results from CW Logs output group - CLI command: `agentcore run evaluation-job --job --session-id ` with --json output and progress callbacks - Tests: 17 new tests covering DP wrappers, runId generation, orchestration (error handling, polling, CW Logs result parsing) * feat: complete US1/US2 quick wins — run name, cancel, update, stage-aware endpoints - Add --run flag to `run evaluation-job` for custom run name prefixes - Add `run cancel-evaluation-job` command with StopEvaluationJobRun DP API - Add `update evaluation-job` primitive method and CLI subcommands - Add `agentcore update experiment` parent command (backward-compatible) - Make CP/DP endpoints stage-aware via AGENTCORE_STAGE env var (beta/gamma/prod) - Fix beta SigV4 service name (bedrock-agentcore vs bedrock-agentcore-control) - Update AddEvaluationJobFlow success screen with next-steps guidance * feat: add TUI run wizard, progress steps, and local result storage for eval jobs - Add RunEvalJobFlow TUI: select job → enter sessions → name run → confirm → execute - Add StepProgress display during eval job polling (starting → polling → fetching → saving) - Add elapsed time counter during run execution - Add eval-job-storage module: save/load/list run results per job in .cli/eval-job-results/ - Auto-save results on both CLI and TUI paths - Add "Evaluation Job" option to TUI Run screen - Add 9 unit tests for eval-job-storage * feat: add CloudWatch session discovery to eval job TUI wizard - Add source type picker: "Discover from CloudWatch" vs "Enter manually" - Add lookback days input (1-90 days) for CloudWatch discovery - Discover sessions via CW Insights query using agent's runtimeId - Multi-select from discovered sessions with span count + timestamps - Auto-fallback to manual entry when agent not deployed (no runtimeId) - Improve error display: show failed step in StepProgress before transitioning * feat: migrate evaluation from resource CRUD to stateless batch evaluation Replace the old EvaluationJob resource model (create/update/delete via agentcore.json + deploy hooks) with a flat BatchEvaluation API model: - Add `run batch-evaluation` and `run stop-batch-evaluation` CLI commands - Add batch evaluation TUI wizard under the Run menu - Add SigV4 API client for batch eval endpoints (start/get/list/stop) - Add CloudWatch results fetching from outputDataConfig - Remove all old evaluation-job infrastructure: primitive, deploy hook, schema, TUI add/remove screens, CP CRUD operations - Remove evaluationJobs from agentcore.json schema Tested end-to-end on gamma (account 998846730471) with Builtin.Faithfulness evaluator against 3 agent sessions — all returning correct scores. * chore: remove executionRoleArn now that FAS creds are live on gamma The batch evaluation API no longer requires an execution role ARN. Remove the --execution-role CLI option and all executionRoleArn plumbing from the API client and orchestration layer. * Revert "chore: remove executionRoleArn now that FAS creds are live on gamma" This reverts commit f1706ff7ea4b7695d1466e609cde29e38cb00afb. * refactor: move stop-batch-evaluation to top-level stop command Move `agentcore run stop-batch-evaluation` to `agentcore stop batch-evaluation` as a higher-level verb, consistent with pause/resume pattern. * fix: evo cleanup — sync public 0.7.1 + 6 bug fixes (#52) * ci: use draft releases for PR tarballs to avoid notifying watchers (#745) * feat: add code-based evaluator support (#739) * feat: add code-based evaluator support Add managed and external code-based evaluator support across schema, CLI flags, TUI wizard, and template scaffolding. Block code-based evaluators from online eval configs at schema, CLI, and TUI layers. * temp: use pyproject.toml with vendored SDK wheel Vendor the SDK wheel and add binary-aware template rendering until the SDK is published to PyPI. To be removed once the SDK is publicly available. * fix: update asset snapshot and regenerate package-lock.json - Update asset file listing snapshot for new evaluator templates - Regenerate package-lock.json to fix stale aws-cdk bundled dep (@aws-cdk/cloud-assembly-schema 52.2.0 -> 53.11.0) * fix: show correct evaluator type in status display Status command was hardcoding "LLM-as-a-Judge" for all evaluators. Now derives the label from item.config.codeBased to distinguish code-based evaluators. * feat: add additionalPolicies field to managed code-based evaluator config Add additionalPolicies to ManagedCodeBasedConfigSchema supporting both inline .json policy files and managed policy ARNs. Auto-populate with execution-role-policy.json when scaffolding managed evaluators. * revert: remove vendored wheel support and requirements.txt The SDK is now on PyPI (bedrock-agentcore>=1.6.0). Remove: - Binary-aware template rendering (.whl copy logic) - Vendored wheels from evaluator assets - requirements.txt references from scaffold messages pyproject.toml now pulls directly from PyPI. * fix: remove vendored wheel and pin bedrock-agentcore>=1.6.0 Remove the last vendored .whl from src/assets and update pyproject.toml to require bedrock-agentcore>=1.6.0 from PyPI. Update asset snapshot accordingly. * feat(import): add runtime and memory import subcommands with TUI wizard (#763) * feat(import): add runtime and memory import subcommands Add `agentcore import runtime` and `agentcore import memory` subcommands to import existing AWS resources into a CLI project. Includes 2-phase CFN import, source code copying, and shared utilities. Also adds TODO.md tracking entrypoint detection improvement and CFN Phase 2 handler investigation, and IMPORT_TESTING_SUMMARY.md with full E2E test results. Constraint: AWS API returns modified entryPoint array (with otel wrapper), not original Constraint: Commander.js parent options shadow same-named child options Rejected: --source flag on runtime subcommand | conflicts with parent import --source Confidence: high Scope-risk: moderate Not-tested: CFN Phase 2 import for runtimes (service-side HandlerInternalFailure) * fix(import): fail on undetectable entrypoint instead of silent fallback extractEntrypoint() now returns undefined when it cannot find a file with a known extension (.py/.ts/.js) in the API's entryPoint array, instead of silently falling back to main.py. Adds --entrypoint flag so users can specify the entrypoint manually when auto-detection fails. Constraint: AWS API returns modified entryPoint array with otel wrappers, not original Rejected: Silent fallback to main.py | wrong entrypoint causes silent deploy failures Confidence: high Scope-risk: narrow * chore: remove TODO.md and testing summary from branch * test(import): add unit tests for entrypoint detection and runtime import handler 11 tests for extractEntrypoint covering otel wrappers, missing/empty arrays, multiple extensions, and extensionless entries. 8 tests for handleImportRuntime covering entrypoint failure, --entrypoint override, missing --code, nonexistent source path, and duplicate runtime names. * fix(import): address PR review feedback - Validate entrypoint file exists inside --code directory - Improve --code help text to clarify it points to the entrypoint folder - Validate AWS credentials match target account via STS GetCallerIdentity - Fix project name prefix stripping to only strip known prefix, not any underscore - Rename sanitize() to replaceUnderscoresWithDashes() for clarity - Use existing Dockerfile template from assets instead of hardcoded duplicate * refactor: change --id to --arn on import runtime and memory subcommands Users now provide the full resource ARN instead of just the ID. The runtime/memory ID is extracted from the ARN's last path segment. * fix(import): extract reflectionNamespaces for EPISODIC memory strategies toMemorySpec was not mapping reflectionNamespaces from the API response, causing EPISODIC strategy imports to fail Zod schema validation which requires reflectionNamespaces for EPISODIC type strategies. * fix(import): validate ARN format, region, and account before extracting resource ID Previously, --arn was parsed with a blind split('/').pop() with no validation. Now parseAndValidateArn checks the ARN matches the expected format, resource type, and that region/account match the deployment target. * fix(import): throw on missing required fields in getMemoryDetail instead of silent defaults Previously, missing id/arn/name/eventExpiryDuration/strategy.type were silently replaced with empty strings or default values, hiding API response issues that would cause broken imports downstream. * fix(import): detect already-imported resources early and improve CFN error messages Check deployed-state.json before making any config changes to catch resources already imported in the current project. Also detect the "already exists in stack" CFN error and provide a friendlier message explaining the resource must be removed from the other stack first. * feat(import): capture tags during memory import Fetch tags via ListTagsForResource API and include them in the imported memory config. Tags already flow through the CLI schema and CDK construct, they just weren't being read from the API during import. * feat(import): capture encryptionKeyArn during memory import Add encryptionKeyArn to CLI schema, MemoryDetail, and toMemorySpec so imported memories preserve their KMS encryption key configuration. Also update CDK L3 construct to pass encryptionKeyArn through to CfnMemory. * feat(import): capture executionRoleArn during memory import Map the API field memoryExecutionRoleArn to executionRoleArn in CLI schema to match the runtime convention. Also update CDK L3 construct to use an imported role via Role.fromRoleArn when executionRoleArn is provided instead of always creating a new one. * refactor(import): deduplicate actions.ts by reusing import-utils utilities actions.ts reimplemented 5 utilities that already exist in import-utils.ts. Replace local definitions with imports and use updateDeployedState() instead of inline state manipulation. Removed: sanitize(), toStackName(), fixPyprojectForSetuptools(), COPY_EXCLUDE_DIRS, copyDirRecursive() — all duplicates of import-utils.ts. * fix(import): paginate listings, auto-select single result, and preserve runtime config Three import bugs fixed: 1. listAgentRuntimes/listMemories only fetched one page (max 100). Added listAllAgentRuntimes/listAllMemories that paginate via nextToken. 2. Single-result listing incorrectly showed "Multiple found" error. Now auto-selects when exactly one runtime/memory exists. 3. toAgentEnvSpec dropped env vars, tags, lifecycle config, and request header allowlist. Extended AgentRuntimeDetail and getAgentRuntimeDetail to extract these fields (including ListTagsForResource call for tags), and mapped them in toAgentEnvSpec. Confidence: high Scope-risk: moderate Not-tested: pagination with >100 real resources (no integration test account available) * test(import): add tests for pagination, field extraction, auto-select, and env var mapping Tests cover: - listAllAgentRuntimes/listAllMemories pagination across multiple pages - getAgentRuntimeDetail extraction of environmentVariables, tags (via ListTagsForResource), lifecycleConfiguration, requestHeaderAllowlist - toAgentEnvSpec mapping of env vars Record to envVars array, plus direct mapping of tags, lifecycle config, and header allowlist - Single-result auto-select when listing returns exactly 1 runtime - Error cases: empty listings, multiple results, absent fields * feat(import): auto-create deployment target from ARN when none exist When no deployment targets are configured, import runtime/memory now parses the --arn to extract region and account, then creates a default target automatically instead of requiring `agentcore deploy` first. * fix(import): omit runtimeVersion for Container builds Container runtimes have no runtimeVersion from the API, but toAgentEnvSpec was hardcoding PYTHON_3_12 as a fallback. Now runtimeVersion is optional in the schema and only set for non-Container builds. * fix(import): filter API-internal namespace patterns from memory import Memory strategies like SUMMARIZATION and USER_PREFERENCE include auto-generated namespace patterns (e.g. /strategies/{memoryStrategyId}/...) that are API-internal and should not be written to local agentcore.json. Constraint: Only filters namespaces containing {memoryStrategyId} template var Rejected: Strip all namespaces for non-SEMANTIC strategies | would lose user-defined namespaces Confidence: high Scope-risk: narrow * fix(import): show project context error before --code flag validation Commander's requiredOption() for --code runs before the action handler, so users outside a project see "required option not specified" instead of "no agentcore project found". Change to option() so the handler's project context check (step 1) runs first. The --code validation at step 5 still catches missing values after project context is confirmed. Constraint: Commander validates requiredOption before action handlers execute Rejected: Moving project check into a Commander hook | adds complexity for one flag Confidence: high Scope-risk: narrow * fix(import): address bugbash issues for import commands - Invalid ARN now returns "Not a valid ARN" before target resolution - Failed imports roll back agentcore.json and clean up copied app/ dirs - Discovery listings show ARNs (not just IDs) so users can copy them - Remove --target flag from import runtime/memory subcommands - Add description field to AgentEnvSpec schema and wire through import Constraint: Commander validates requiredOption before action handlers Constraint: Rollback is best-effort to avoid masking the original error Rejected: Keep --target on subcommands | silently falls back to default, confusing UX Confidence: high Scope-risk: moderate * feat(import): add interactive TUI wizard for import command Adds a multi-screen TUI flow for importing runtimes, memories, and starter toolkit configs, replacing the silent fall-through that previously occurred when selecting "import" in the TUI. Constraint: onProgress must be injectable so TUI can display step progress Rejected: Single text-input screen for all flows | each import type has different required fields Confidence: high Scope-risk: narrow * fix(import): add early name validation and allow re-import with --name Bug 5: Validate --name against the AgentNameSchema regex before any file I/O operations. Previously, a malicious --name like '../../../etc/pwned' would copy files outside the project directory and set up a Python venv there before schema validation rejected it. Now invalid names are caught immediately with a clear error message. Applied to both import-runtime and import-memory. Bug 6: Allow re-importing the same cloud resource under a different local name when --name is provided. Previously, the deployed-state duplicate check blocked all re-imports by resource ID regardless of --name. Now it only blocks when --name is not provided, and suggests using --name in the error message. When --name is provided, it warns and proceeds. Applied to both import-runtime and import-memory. * revert(import): restore original duplicate-by-ARN blocking behavior Bug 6 is not a bug — blocking re-imports of the same cloud resource ARN is correct because allowing it would create duplicate CFN logical resources referencing the same physical resource, causing deploy failures. Reverts the --name re-import allowance while keeping the Bug 5 early name validation fix. * feat(import): mark import command as experimental * fix(import): wire deploy next-step navigation and show dotfiles in file picker Two TUI fixes for the import flow: 1. ImportFlow now accepts onNavigate prop so selecting "Deploy" from next steps navigates to the deploy screen instead of going back. 2. PathInput gains a showHidden prop; YamlPathScreen uses it so .bedrock_agentcore.yaml is visible in the file picker. * refactor(import): extract shared CDK import pipeline to eliminate duplication The three import handlers (import-runtime, import-memory, actions) all repeated the same CDK build/synth/bootstrap/publish/phase1/phase2/state-update pipeline (~120 lines each). Extract this into executeCdkImportPipeline() in a new import-pipeline.ts module. Also add resolveImportContext() and failResult() helpers to import-utils.ts for shared setup and error handling. Net effect: -335 lines, zero behavior change, all 260 tests pass. Constraint: Must not change any observable behavior — pure structural refactor Rejected: Full strategy-pattern abstraction | over-engineering for 2 concrete cases Confidence: high Scope-risk: moderate Not-tested: actions.ts YAML import path with real AWS (infra limitation) * fix(import): launch TUI wizard when running agentcore import with no args Previously `agentcore import` with no --source flag showed help text. Now it launches the interactive ImportFlow TUI, matching the pattern used by `agentcore add` and other commands. * fix(import): wire deploy and status navigation from CLI-inline TUI When running `agentcore import` from CLI (not full TUI), selecting "deploy" or "status" from the next-steps menu now renders the corresponding screen instead of silently exiting. * style(import): fix prettier formatting in TUI screens * fix(security): update lodash and lodash-es to resolve high-severity vulnerabilities npm audit fix resolves CVE for code injection via _.template and prototype pollution via _.unset/_.omit in lodash <=4.17.23. * refactor(aws): extract createControlClient to avoid per-call client instantiation Each function in agentcore-control.ts was creating a new BedrockAgentCoreControlClient on every call, wasting HTTP connections and credential resolution. Extracted a shared createControlClient() factory and reuse a single client across paginated listAll* calls. * fix(import): log warnings on silent catch failures instead of swallowing errors Tag fetch failures in agentcore-control.ts and rollback failures in import-runtime.ts and import-memory.ts were silently swallowed. Users had no indication when config could be left in a broken state. Added console.warn calls matching the existing pattern in bedrock-import.ts. --------- Co-authored-by: Aidan Daly * fix(ci): regenerate lockfile for npm 11 compatibility (#770) npm 11 (shipped with Node 24.x) requires all optional dependency entries in the lock file, even for non-matching platforms. The lock file was generated with npm 10, which only records the current platform's optional deps (@esbuild/linux-x64). This caused npm ci to fail on the Node 24.x CI matrix entry with "Missing: @esbuild/* from lock file" errors. Regenerated with npm 11 to include all 26 @esbuild/* platform entries. Constraint: Lock file must be compatible with npm 10 (Node 20/22) and npm 11 (Node 24) Rejected: Pin CI to npm 10 | would mask the issue and delay migration Confidence: high Scope-risk: narrow Co-authored-by: Claude Opus 4.6 * ci: block schema changes in PRs (#712) The JSON schema is served live from the repo, so any commit to main that modifies it is effectively a release. Only the release workflow should regenerate and commit schema changes. * chore: bump version to 0.6.0 (#771) Co-authored-by: github-actions[bot] * fix: make add command description consistent with remove (#773) * chore(deps): bump vite from 8.0.3 to 8.0.5 (#777) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.0.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(fetch): add --identity-name option for custom credential lookup (#715) (#774) The `fetch access` command hardcoded credential lookup to `-oauth` via `computeManagedOAuthCredentialName()`, causing failures when users create identities with custom names. This adds an `--identity-name` option that lets users specify which credential to use for OAuth token fetch, falling back to the default convention when omitted. When no matching credential is found, the error message now lists all available OAuth credentials and suggests using `--identity-name`. Constraint: Must remain backward compatible — omitting --identity-name preserves existing behavior Rejected: Modify computeManagedOAuthCredentialName globally | would break other consumers Confidence: high Scope-risk: narrow Not-tested: TUI interactive flow and invoke command auto-fetch paths (noted as follow-up) * feat(status): display runtime invocation URL for deployed agents (#775) Show the runtime invocation URL in agentcore status output for each deployed agent. The URL is computed from the runtime ARN and target region, and displayed in CLI text output, JSON output, and the TUI ResourceGraph component. URL format: https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{encodedArn}/invocations Closes #716 Constraint: URL is only available when both targetConfig and runtimeArn exist Rejected: Reuse existing buildInvokeUrl from agentcore.ts | includes ?qualifier=DEFAULT which is for API invocation, not display Confidence: high Scope-risk: narrow * feat(create): add --skip-install flag to skip dependency installation (#782) Adds a --skip-install flag to `agentcore create` that skips all dependency installation (npm install for CDK and uv sync for Python). This enables enterprise users behind corporate proxies or private registries to modify package.json/pyproject.toml before installing dependencies manually. The flag sets the existing AGENTCORE_SKIP_INSTALL env var (previously only used in tests) and also implies --skip-python-setup behavior. A post-create message instructs users to install manually. * feat(import): add evaluator and online eval config import subcommands (#780) * feat(import): add evaluator import subcommand with TUI wizard Add `agentcore import evaluator` to import existing AWS evaluators into CLI projects. Refactor import types and utilities for extensibility so future resource types require minimal new code. Changes: - Add import-evaluator.ts handler with toEvaluatorSpec mapping (LLM-as-a-Judge and code-based evaluators), duplicate detection, and CDK import pipeline - Enhance getEvaluator API wrapper to extract full evaluatorConfig (model, instructions, ratingScale) and tags from SDK tagged unions - Add listAllEvaluators pagination helper filtering out built-in evaluators - Widen ImportableResourceType union and shared utilities for evaluator support - Add evaluator to TUI import flow (select, ARN input, progress screens) - Add 17 unit tests covering spec conversion, template lookup, and error cases Tested end-to-end against real AWS evaluator (bugbash_eval_1775226567-zrDxm7Gpcw) with verified field mapping for all config fields, tags, and deployed state. * fix(import): use correct importType for evaluator in TUI flow The TUI import wizard hardcoded importType as 'memory' for all non-runtime resources, causing evaluator imports to fail with "ARN resource type evaluator does not match expected type memory". Use flow.resourceType instead so the correct handler is dispatched. * feat(import): add online eval config import subcommand Add `agentcore import online-eval` to import existing online evaluation configs from AWS into CLI-managed projects. Follows the same pattern as runtime, memory, and evaluator imports. The command extracts the agent reference from the config's service names (pattern: {agentName}.DEFAULT), maps evaluator IDs to local names or ARN fallbacks, and runs the full CDK import pipeline. Also removes incorrect project-prefix stripping from evaluator and runtime imports — imported resources come from outside the project and won't have the project prefix. Constraint: Agent must exist in project runtimes[] before import (schema enforces cross-reference) Constraint: Evaluators not in project fall back to ARN format to bypass schema validation Rejected: Loose agent validation | schema writeProjectSpec() enforces runtimes[] cross-reference Confidence: high Scope-risk: moderate * feat(import): add online eval config to TUI import wizard Add 'Online Eval Config' option to the interactive import flow so users can import online evaluation configs via the TUI, not just the CLI. Follows the same ARN-only pattern as evaluator and memory imports: select type → enter ARN → import progress → success/error. * docs: add TUI import wizard screenshots for online eval Screenshots captured from the TUI import flow showing: - Import type selection menu with Online Eval Config option - ARN input screen for online eval config - ARN input with a real config ARN filled in * Revert "docs: add TUI import wizard screenshots for online eval" This reverts commit cb4c6757e66ffefe05c974d44e34754cff216196. * refactor(import): extract generic import orchestrator with descriptor pattern Reduce ~1,400 lines of duplicated orchestration across four import handlers (runtime, memory, evaluator, online-eval) to ~600 lines by extracting shared logic into executeResourceImport(). Each resource type now provides a thin descriptor declaring its specific behavior. Constraint: Public handleImport* function signatures unchanged (TUI depends on them) Constraint: Factory functions needed for runtime/online-eval to share mutable state between hooks Rejected: Strategy class hierarchy | descriptor objects are simpler and more composable Confidence: high Scope-risk: moderate * refactor(aws): extract paginateAll and fetchTags helpers in agentcore-control Deduplicates identical pagination loops across 4 listAll* functions and identical tag-fetching try/catch blocks across 3 getDetail functions. Also adds optional client param to listEvaluators and listOnlineEvaluationConfigs for connection reuse during pagination. Addresses deferred review feedback from PR #763. Constraint: evaluator listAll still filters out Builtin.* entries Confidence: high Scope-risk: narrow * fix(import): resolve evaluator references via deployed state for imported evaluators resolveEvaluatorReferences used string-contains matching (evaluatorId.includes(localName)) which only works when the evaluator was deployed by the same project. Imported evaluators with renamed local names never matched, falling back to raw ARNs in the config. Now reads deployed-state.json to build an evaluatorId → localName reverse map and checks it first, before the string-contains heuristic. Constraint: Deployed state may not exist yet (first import) — .catch() handles gracefully Rejected: Passing deployed state through descriptor interface | only online-eval needs this Confidence: high Scope-risk: narrow * fix(import): auto-disable online eval configs to unlock evaluators during import Evaluators referenced by ENABLED online eval configs are locked by the service (lockedForModification=true), causing CFN import to fail when it tries to apply stack-level tags. Now the evaluator import detects the lock, temporarily disables referencing online eval configs, performs the import, then re-enables them. Constraint: Re-enable runs in finally block so configs are restored on both success and failure Constraint: Only disables configs that actually reference this specific evaluator Rejected: Refuse import with manual guidance | user can't pause configs not yet in project Confidence: high Scope-risk: moderate * Revert "fix(import): auto-disable online eval configs to unlock evaluators during import" This reverts commit 583939153e336a72c6e5cd425dd02a834d73b9d0. * fix(import): block evaluator import when referenced by online eval, use ARN-only references Evaluators locked by an online eval config cannot be CFN-imported because CloudFormation triggers a post-import TagResource call that the resource handler rejects. Instead of stripping tags from the import template, block the import with a clear error and suggestion to use import online-eval. Online eval config import now always references evaluators by ARN rather than resolving to local names, since the evaluators cannot be imported into the project alongside the config. Constraint: CFN IMPORT triggers TagResource which fails on locked evaluators Rejected: Strip Tags from import template | still fails on some resource types Confidence: high Scope-risk: narrow * fix(import): resolve OEC agent reference via deployed state when runtime has custom name extractAgentName() derives the AWS runtime name from the OEC service name pattern, but this fails to match when the runtime was imported with --name since the project spec stores the local name. Now falls back to listing runtimes to find the runtime ID, then looks up the local name in deployed-state.json. * fix(import): strip CDK project prefix from OEC service name when resolving agent CDK constructs set the OEC service name as "{projectName}_{agentName}.DEFAULT". extractAgentName() strips ".DEFAULT" but not the project prefix, so the lookup fails against local runtime names. Now strips the prefix as a fast path before falling back to the deployed-state API lookup. * fix(import): show friendly error for non-existent evaluator ID getEvaluator() now catches ResourceNotFoundException and ValidationException from the SDK and rethrows a clear message instead of exposing the raw regex validation error. * fix(import): validate ARN resource type for online-eval import import online-eval used a naive regex to extract the config ID from the ARN, skipping resource type, region, and account validation. Now uses parseAndValidateArn like all other import commands. Added an ARN resource type mapping to handle the online-eval vs online-evaluation-config mismatch between ImportableResourceType and the ARN format. * refactor(import): address PR review feedback - Add `red` to ANSI constants, replace inline escape codes - Type GetEvaluatorResult.level as EvaluationLevel at boundary - Combine ARN_RESOURCE_TYPE_MAP, collectionKeyMap, idFieldMap into single RESOURCE_TYPE_CONFIG to prevent drift - Export IMPORTABLE_RESOURCES as const array, derive type from it, replace || chains with .includes() - Fix samplingPercentage === 0 false positive (use == null) - Document closure state sequencing contract on descriptor hooks * test(import): remove unreachable empty-level evaluator test The test exercised a defensive fallback in toEvaluatorSpec for an empty level string, but now that GetEvaluatorResult.level is typed as EvaluationLevel, the boundary cast in getEvaluator prevents this case from ever reaching toEvaluatorSpec. * feat: add custom dockerfile support for Container agent builds (#783) * feat: add custom dockerfile support for Container agent builds Add an optional `dockerfile` field to Container agent configuration, allowing users to specify a custom Dockerfile name (e.g. Dockerfile.gpu) instead of the default "Dockerfile". Changes across all layers: - Schema: Add dockerfile field to AgentEnvSpecSchema with filename validation - CLI wizard: Add "Custom Dockerfile" option to Advanced settings multi-select, with dedicated Dockerfile input step in the breadcrumb wizard - Dev server: Thread dockerfile through container config to docker build - Deploy preflight: Validate custom dockerfile exists before deploy - Packaging: Pass dockerfile to container build commands - Security: getDockerfilePath rejects path traversal (/, \, ..) - Tests: 64 new/updated tests across schema, preflight, dev config, packaging, wizard, and constants Constraint: Dockerfile must be a filename only (no path separators) Rejected: Accept full paths | path traversal security risk Rejected: Auto-copy Dockerfile on create | users manage their own Dockerfiles Confidence: high Scope-risk: moderate Not-tested: Interactive TUI tested manually via TUI harness (not in CI) * chore: remove dead code and redundant tests from dockerfile PR - Remove unused ADVANCED_GROUP_LABELS constant (dead code) - Remove unnecessary export on DOCKERFILE_NAME_REGEX - Fix stale `steps` dependency in useGenerateWizard setAdvanced callback - Trim computeByoSteps.test.ts to dockerfile-only tests (remove 11 tests for pre-existing behavior unchanged by this PR) - Remove redundant "uses default Dockerfile" tests that duplicate existing coverage in preflight, config, and container packager test files - Consolidate shell metacharacter it.each from 5 cases to 1 representative Confidence: high Scope-risk: narrow * fix: skip template Dockerfile scaffolding when custom dockerfile is set When a custom dockerfile is configured (e.g. Dockerfile.gpu), the renderer was still copying the default template Dockerfile into the agent directory, leaving an unused file alongside the custom one. Thread the dockerfile config through AgentRenderConfig and use a new exclude option on copyAndRenderDir to skip the template Dockerfile when a custom one is specified. The .dockerignore is still scaffolded. Constraint: copyAndRenderDir is a shared utility used by all renderers Rejected: Delete template after render | user requested option A (don't create) Confidence: high Scope-risk: narrow * fix: use PathInput file picker for Dockerfile selection in TUI Replace the TextInput with PathInput for Dockerfile selection in both the BYO add-agent and Generate wizard flows. This gives users a real file browser with directory navigation and existence validation on submit, matching the UX pattern used by the policy file picker. BYO flow: PathInput scoped to the agent's code directory so users browse their existing files and pick a Dockerfile. Generate flow: PathInput scoped to cwd so users browse the filesystem to find a Dockerfile to copy into the new project. Added allowEmpty and emptyHelpText props to PathInput so users can press Enter to use the default Dockerfile. Constraint: PathInput is a shared component used by policy and import screens Rejected: Soft warning on TextInput | user preferred real file picker like policy Confidence: high Scope-risk: narrow * feat(invoke,dev): add exec mode for running shell commands in runtimes (#750) * feat(invoke,dev): add exec mode for running shell commands in runtimes Add ! exec mode to invoke TUI for running shell commands in deployed runtimes, and ! local exec / !! container exec to dev TUI. Includes non-interactive --exec flag for both invoke and dev commands. - invoke TUI: type ! to enter exec mode, runs commands in deployed runtime - invoke CLI: --exec flag runs commands via InvokeAgentRuntimeCommand API - dev TUI: type ! for local exec, !! for container exec (Container builds) - dev CLI: --exec flag execs into running dev container (Container only) - TextInput: add onChange and onBackspaceEmpty props for mode switching - Pink/magenta hint text appears when exec input is empty, disappears on typing - Backspace on empty input reverses mode (!! -> ! -> normal) - IAM policy and docs updated with new bedrock-agentcore:InvokeRuntimeCommand Constraint: dev --exec CLI is Container-only since CodeZip users have local terminal Rejected: Ctrl+E hotkey for container exec | !! double-bang is more discoverable and consistent Confidence: high Scope-risk: moderate * fix: address review findings — side effects, DRY, dead code, validation CRITICAL: Move onBackspaceEmpty callback outside setState updater (React purity violation) and add text.length === 0 guard so it only fires when input is truly empty. HIGH: Extract shared runSpawnCommand helper in useDevServer to DRY up execCommand/execInContainer near-duplication (~100 lines → ~60 lines). Deslop: Remove dead providerInfo/modelProvider code paths, simplify singleValueStream to plain yield, replace options.prompt! assertions with early guard, remove redundant comments. Medium: Fix timeout:0 falsy check, add --exec+--stream validation, handle undefined exitCode explicitly, add SDK cast comment + runtime guard. Constraint: onBackspaceEmpty must fire outside setState to avoid double-fire in React 18 concurrent mode Rejected: Checking state inside updater for callback | React purity violation Confidence: high Scope-risk: narrow * feat(tui): sticky exec mode with distinct visual styling - Exec prompt (!) now uses magenta instead of yellow for clear differentiation from chat prompt (>) - Exec output renders in default terminal foreground, distinct from green chat responses — works on both dark and light terminals - Exec mode is sticky: ! prompt persists after running a command, exit via Escape or Backspace-on-empty - Conversation rendering upgraded to per-line colored output in InvokeScreen (matching DevScreen pattern) Constraint: Terminal color palette limited to 8 base colors Rejected: cyan for exec output | too similar to blue chat input Rejected: yellow for exec output | invisible on light terminal backgrounds Confidence: high Scope-risk: narrow * fix(tui): resolve 3 exec mode UX bugs from review feedback Bug 1: Escape in exec mode no longer exits the app. The Screen component's useExitHandler is disabled when in input mode (exitEnabled={mode !== 'input'}), so only TextInput's onCancel handles Escape. Escape in exec drops to > prompt; Escape from > goes to chat mode; Escape from chat exits. Bug 2: Dim prompt during command execution now shows ! or !! instead of always showing >, matching the current exec state. Bug 3: execInputEmpty is reset to true after command execution, so typing ! in sticky exec mode correctly escalates to !! (container exec) on container agents. * fix(tui): eliminate command flash during exec mode transitions The !! command and prompt would briefly vanish (replaced by >) when executing a container command, because setMode('chat') fired in a separate render batch from setConversation/setIsStreaming. Fix: add onStart callback to runSpawnCommand that fires in the same synchronous block as the conversation/streaming state updates, so React batches them together. The mode transition and conversation update now render atomically — no flash. * chore: bump version to 0.7.0 (#784) Co-authored-by: github-actions[bot] * fix(ci): pin npm version to avoid self-upgrade corruption (#785) npm install -g npm@latest fails on GitHub Actions runners when npm tries to replace its own modules mid-installation, corrupting the promise-retry dependency. Pin to npm@11.5.1 which is the minimum version needed for OIDC trusted publishing. * chore: bump version to 0.7.1 (#786) Co-authored-by: github-actions[bot] * fix: evo cleanup — 6 fixes for config bundles, recommendations, and batch eval 1. Remove "List Recommendations" from TUI hub (unimplemented API call) 2. Config bundle TUI hub reads from project config (agentcore.json) instead of listing all bundles from the API 3. Default config bundle branch name changed from "mainline" to "main" 4. Tool description recommendation: remove evaluator step (API does not accept evaluators); System prompt recommendation: single-select evaluator (exactly 1 required) 5. Tool description TUI: remove duplicate inputSource/content steps (tools step already collects tool name:description pairs) 6. Batch eval: only send executionRoleArn when explicitly provided via --execution-role flag (FAS creds work without it) * fix: standardize CLI flags, error handling, and batch eval TUI - Rename --agent to --runtime in batch eval and recommendation commands - Rename --days to --lookback in run eval command - Make --evaluator optional for tool-description recommendations (matches API) - Standardize evaluator flag descriptions across all run subcommands - Make deleteConfigurationBundle throw instead of returning {success, error} - Remove requestId from recommendation API error messages - Silence non-fatal tag fetch warnings in agentcore-control - Add StepProgress component to batch eval TUI with elapsed timer * feat: surface batch eval evaluatorSummaries and session stats - Add EvaluatorSummary and EvaluationResults types to batch eval API client - Extract evaluationResults from GetBatchEvaluation API response - CLI and TUI prefer API-provided evaluatorSummaries (averageScore, totalEvaluated, totalFailed) over local computation when available - Show session stats (total, completed, failed) in both CLI and TUI - Persist evaluationResults in saved batch eval records - Falls back to local CloudWatch-based aggregation when API summaries are not yet available (gamma currently returns session counts only; evaluatorSummaries is in beta and will roll forward) * docs: improve CLI help text, descriptions, and examples - Fix `run` parent description (was eval-only, now covers all 3 subcommands) - Fix `--agent-arn` reference in copy.ts (renamed to --runtime-arn) - Fix logs example using --agent (now --runtime) - Improve `evals` and `recommendations` descriptions for clarity - Add ground truth context to -A, --expected-trajectory, --expected-response - Improve recommendation option descriptions (--inline, --tools, --bundle-name) - Clarify --lookback as "How far back to search for traces in CloudWatch" - Clarify --evaluator as "required for system-prompt, optional for tool-description" - Remove "temporary — will be removed" from --execution-role - Improve batch eval description to mention CloudWatch sessions - Add CLI_ONLY_EXAMPLES for run eval, run batch-evaluation, run recommendation, config-bundle, stop, and evals commands --------- Signed-off-by: dependabot[bot] Co-authored-by: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Co-authored-by: Trirmadura J Ariyawansa Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Co-authored-by: Aidan Daly Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> * fix(ci): exclude .github/workflows/ from public repo sync (#54) GITHUB_TOKEN lacks the 'workflows' permission, so pushing workflow file changes from the public repo causes the sync to fail. Use --no-commit --no-ff and restore .github/workflows/ from HEAD before committing, in both the clean merge and conflict paths. * fix: evaluator resolution + config bundle filter field name (#55) * fix: resolve custom evaluator names to deployed IDs in batch eval Batch eval was sending project-level evaluator names (e.g. "MyCustomEval") instead of deployed evaluator IDs (e.g. "cbtest_MyCustomEval-xv2w2e42GL"). Builtin.* evaluators worked because the service resolves them directly, but custom evaluators need the deployed ID. Adds evaluator name resolution from deployed state, matching how run eval already resolves custom evaluator names in run-eval.ts. * fix: correct config bundle version filter field name to match API The ListConfigurationBundleVersions API expects `createdByName` (string) in the version filter, but the CLI was sending `createdBy` (string[]). This caused the filter to be silently ignored by the API. * fix: tool description comma parsing in recommendations (#56) CLI --tools option changed from comma-separated string to variadic (--tools "search:desc" --tools "calc:desc") so commas in descriptions don't break parsing. TUI uses a smarter regex split that only splits on commas followed by a valid tool name pattern and colon. * feat: add A/B test CLI support - ABTestPrimitive with add/remove following config bundle pattern - SigV4-signed HTTP client for AgentCore Evaluation Data Plane API - Post-deploy reconciliation creates/deletes AB tests + IAM roles - TUI: add wizard, remove flow, compact dashboard detail screen - CLI commands: add, remove, pause, resume, stop, view details - Auto-creates project-scoped IAM role with least-privilege permissions - Post-deploy warnings surfaced in TUI deploy screen - Comprehensive unit, hook, and integration tests * fix: route config-bundle to interactive TUI instead of CLI-only help screen config-bundle was listed in CLI_ONLY_EXAMPLES which intercepted the command before it could reach the ConfigBundleFlow TUI route, showing a static usage screen instead of the interactive bundle hub. * feat: add A/B test CLI support (#50) - ABTestPrimitive with add/remove following config bundle pattern - SigV4-signed HTTP client for AgentCore Evaluation Data Plane API - Post-deploy reconciliation creates/deletes AB tests + IAM roles - TUI: add wizard, remove flow, compact dashboard detail screen - CLI commands: add, remove, pause, resume, stop, view details - Auto-creates project-scoped IAM role with least-privilege permissions - Post-deploy warnings surfaced in TUI deploy screen - Comprehensive unit, hook, and integration tests * fix: TUI bug fixes + config bundle recommendation support (#57) * fix: batch eval name allows blank + evaluator list scrollable - Add allowEmpty to batch eval name TextInput so users can leave it blank for auto-generated names (placeholder said "leave blank" but validation blocked empty input) - Add maxVisibleItems={10} to evaluator WizardSelect/WizardMultiSelect in both recommendation and batch eval flows to prevent list overflow when many evaluators exist (enables arrow-key scrolling) * feat: add config bundle selection to recommendation TUI wizard Add 'Config bundle' as a third input source option in the recommendation TUI wizard (alongside 'Enter inline' and 'Load from file'). When selected, users can pick from deployed config bundles to use their system prompt as the recommendation input. - Add ConfigBundleItem type and 'bundle' step to wizard flow - Load deployed config bundles during initialization - Pass bundleName/bundleVersion to runRecommendationCommand - Only show config-bundle option when bundles are deployed * fix: config bundle recommendation uses local system prompt instead of broken API path The systemPromptJsonPath field in the config bundle API is broken server-side (all JSON path formats resolve to empty). Work around this by reading the system prompt from the local agentcore.json project config and passing it as inline text to the recommendation API. Also adds config bundle as an always-visible input source option in the recommendation TUI wizard, with proper empty-state handling when no bundles are deployed. * fix: single evaluator + config bundle field selection for recommendations (#61) * fix: enforce single evaluator for recommendations + bundle field selection - Recommendation API accepts exactly one evaluator for system-prompt (min:1, max:1) and none for tool-description. CLI flag changed from variadic --evaluator to singular --evaluator . Operations layer validates count before hitting API. - Config bundle recommendation now lets users pick which configuration field contains the system prompt, instead of guessing key names. New "Prompt Field" wizard step shows all string fields from the selected bundle's configuration. - Validates system prompt content is non-empty before API call, preventing the text:"" → 400 error reported by testers. * feat: add config bundle support for tool description recommendations - TUI wizard now shows config-bundle as input source for tool descriptions - bundleField (singular) → bundleFields (array) to support multi-select - System prompt: single-select picks one field as prompt content - Tool description: multi-select picks fields as toolName:description pairs - Fix confirm screen to display bundleFields correctly - Skip "Tools" confirm field when using config-bundle input * fix: use single-select for bundle field when only one field available - Tool desc with 1 field: WizardSelect (Enter to pick) - Tool desc with 2+ fields: WizardMultiSelect (Space to toggle) - Footer shows "Space to select" hint for multi-select mode * feat: add lookback days and session selection to batch evaluation - AWS client: add sessionInput (sessionIds + sessionFilterConfig) to CloudWatchSource - Operations: accept sessionIds and lookbackDays, compute time range, pass to API - CLI: add -d/--lookback-days and -s/--session-ids options for batch-evaluation - TUI: add lookback days input and session discovery/multi-select to batch eval wizard - Fix nit: remove "API accepts" mention from evaluator help text * fix: send sessionIds or sessionFilterConfig, not both API requires either sessionIds OR sessionFilterConfig in cloudWatchSource.sessionInput. When sessionIds are provided (from session picker), skip the time filter. * feat: batch eval ground truth + config bundle & status fixes (#62) * feat: ground truth support for batch evaluation (CLI + TUI) - Add sessionMetadata types (assertions, expected trajectory, turns) - Add --ground-truth/-g CLI flag to load ground truth from JSON file - Add TUI ground truth step with skip/file/inline options - Use PathInput file picker for ground truth file selection - Fix evaluator ARN resolution (extract short name from full ARN) - Merge session IDs from ground truth metadata with wizard selection - Fix PathInput Enter key to auto-select highlighted dropdown item - Fix TUI wizard Esc to go back one step instead of exiting * fix: display config bundles and ab tests in CLI status command The status command was computing statuses for config-bundle and ab-test resources but never rendering them. Also fixes empty target name display. * fix: resolve stale config bundle IDs with API fallback When config bundles are recreated, deployed-state.json retains old bundle IDs that no longer exist. Both CLI and TUI now fall back to the list API when the deployed-state bundleId returns a 404, fixing the "no differences found" issue in config bundle diff. * fix: batch eval uses deployed region from aws-targets Instead of relying solely on detectRegion() (which defaults to us-east-1), batch evaluation now reads the region from aws-targets.json first. This ensures it runs against the region where the agent is actually deployed. Applies to both CLI and TUI flows. * feat: add placeholder and format hint for config bundle components input Shows expected JSON format (component ARN → configuration map) as a hint above the inline input and as placeholder text. Also adds placeholder for file path input. * fix: improve config-bundle --help to clarify bundle name usage Clarifies that --bundle takes the bundle name from agentcore.json (not the AWS bundle ID), and that --from/--to version IDs come from the versions subcommand output. * fix: pass branchName to getConfigurationBundle API calls The GetConfigurationBundle API now requires a branchName query parameter. Without it, the API returns 404 "Configuration bundle branch not found". This was causing config-bundle versions/diff commands to fail even after the stale ID fallback resolved the correct bundle ID. * fix: show log file path on failure and saved results path on success For batch eval and recommendation commands (both CLI and TUI): - On failure: show the log file path so users can debug - On success: show the saved results file path (recommendation CLI was missing this; batch eval TUI now shows it too) * feat: AB test HTTP gateway, trace delivery, and TUI improvements (#63) * feat: add stop commands and Esc-to-stop for batch eval & recommendation (#65) * fix: always show gateway step in AB test wizard instead of silently skipping (#67) When no existing gateways are available, the wizard silently skipped the gateway step, leaving customers unaware that a gateway would be auto-created. Now the step always shows with "Create new HTTP gateway" as the only option, making the auto-creation explicit and visible. * feat: bundle Python SDK wheel into CLI for offline install - Add src/assets/wheels/ directory for bundled Python wheels - Add --find-links to uv pip install args (async + sync) to prefer local wheels over PyPI when present - Add wheel copy step to bundle.mjs for redundant safety - Include bedrock_agentcore-1.6.0 wheel from feat/evo_main branch When the CLI is installed from a bundle tarball, agentcore deploy will automatically use the bundled SDK wheel instead of fetching from PyPI, enabling fully offline preview distributions. * Fix __dirname in ESM bundle, update versions for evo-private-beta - Add __dirname/__filename shims to esbuild banner so bundled ESM CLI can resolve paths correctly (fixes ReferenceError at runtime) - Fix BUNDLED_WHEELS_DIR path: ../assets/wheels from dist/cli/ instead of ../../assets/wheels which resolved to the wrong directory - Bump CLI version to 0.8.0-evo-private-beta to avoid collision with public 0.8.0 release - Replace Python SDK wheel with 1.6.0.dev20260410 built from feat/evo_main branch of bedrock-agentcore-sdk-python-private * chore: update CLI version to 0.8.0-evo-private-beta-20260410 * fix: invocation URL in AB test views and listHttpGatewayTargets parsing (#68) * fix: show full invocation URL in AB test views and fix listHttpGatewayTargets parsing - Remove gateway URL from agent status view (only show for AB tests) - Rename 'Gateway URL' to 'Invocation URL' across AB test views - Fix listHttpGatewayTargets to parse 'items' key from API response - Fetch target name in AB test detail screen for full invocation URL - Add unit tests for listHttpGatewayTargets * chore: remove overwritten test file * fix: resolve config bundle ARN and recommendation improvements (#71) * fix: resolve config bundle ARN from deployed state for recommendations The recommendation CLI command was passing the human-readable bundle name (e.g. "MyBundle") as the bundleArn field in the API request instead of the actual ARN. This caused a 400 validation error from the API. Now resolves the bundle ARN from deployed state when using --bundle-name, and passes ARN values through directly (for TUI which already stores ARNs). Also includes: - Config bundle support in deploy preflight and CDK templates - JSONPath dot notation for config bundle field resolution - Session ID auto-fetch for all recommendation types (not just tool-desc) - Conditional tool explanation display (hide when empty) - CLI progress output for non-TUI recommendation command - Request IDs included in API error messages for debugging - Lint fixes in preflight.ts (type safety, deduplicated imports) * feat: auto-apply recommendation results to config bundles When recommendations use a config bundle as input source, automatically apply the results back to agentcore.json. CLI auto-applies after display, TUI offers an "Apply to config bundle" action. Supports both system prompt and tool description recommendations via JSONPath resolution. * refactor: sync config bundle from server instead of local apply The server automatically creates a new config bundle version when a recommendation uses a config bundle as input. Instead of locally parsing JSONPath and writing recommended text, fetch the server-created version via GetConfigurationBundleVersion and sync local agentcore.json + deployed state to match. * fix: hide empty explanation in recommendation results Check for non-empty explanation before displaying in both CLI and TUI. The API sometimes returns an empty string for explanation fields. * fix: remove hardcoded branchName 'main' in config bundle resolution resolveBundleByName was passing branchName: 'main' to GetConfigurationBundle, causing 404 errors for bundles on other branches (mainline, experiment, etc.). Omit branchName so the API returns the latest version regardless of branch. * feat: simplify add config bundle TUI with component picker Replace raw JSON/ARN input with a guided flow: select component type (runtime/gateway), pick from deployed resources, enter just the config values. Supports adding multiple components via "Add another?" loop. * fix: remove incorrect CW unsupported disclaimer for tool-desc recommendations CloudWatch trace source is supported for tool description recommendations. Remove the misleading note that said otherwise. * feat: support CloudWatch logs trace source for tool-desc recommendations * feat: add create config bundle option in AB test variant picker * feat: add batch eval history screen and duration hint - New BatchEvalHistoryScreen in evals hub for viewing past batch eval results - Add "Run Batch Evaluation" and "Batch Eval History" to evals hub - Add "This may take a few minutes..." message in CLI and TUI during batch eval * fix: config bundle Esc on componentType goes back instead of addAnother Pressing Esc on the component type step was incorrectly navigating to the "add another?" step when components already existed. Now it always goes to the previous step (description) as expected. * fix: resolve {{agent:name}} placeholder in config bundle component keys The resolveComponentKey function handled {{runtime:name}} and {{gateway:name}} but not {{agent:name}}, which is the documented… * fix: remove dead preflight patch and use proper teardown for evo resources (#1072) - Preflight: remove config bundle type patching entirely — the CDK schema does not have a type field, so neither the on-disk write (original) nor the in-memory patch was needed - Teardown: delegate to deleteOrphanedABTests and deleteOrphanedHttpGateways instead of raw deleteABTest/deleteHttpGatewayWithTargets — reuses stop/poll/delete/role-cleanup logic. Correct ordering: AB tests first (they create rules on gateways), then gateways, then config bundles. Closes #1070 * fix: remove dead preflight patch, proper teardown, optional evo schema fields (#1073) - Preflight: remove config bundle type patching entirely — the CDK schema does not have a type field, so the patch was dead code - Teardown: delegate to deleteOrphanedABTests and deleteOrphanedHttpGateways instead of raw API calls — reuses stop/poll/delete/role-cleanup logic. Correct ordering: AB tests first (they create rules on gateways), then gateways, then config bundles. - Schema: change configBundles, abTests, httpGateways from .default([]) to .optional() so they don't appear in agentcore.json unless the user opts into those preview features. All access sites updated with ?? [] or ??= guards. Closes #1070 * fix: revert .optional() to .default([]) and strip empty evo arrays on write (#1074) The .optional() change from #1073 breaks Zod JSON Schema generation ("Undefined cannot be represented in JSON Schema"). Revert to .default([]) so schema generation and parsing work, but strip empty configBundles/abTests/httpGateways in writeProjectSpec before writing to disk so preview features don't pollute agentcore.json. * fix: remove unnecessary non-null assertions after .default([]) revert (#1075) * chore: bump version to 0.13.0 (#1076) Co-authored-by: github-actions[bot] * docs: remove CrewAI from supported frameworks (#1059) CrewAI is not implemented as a framework option in the CLI — it does not exist in SDKFrameworkSchema, has no template assets, and is not in the SDK_MODEL_PROVIDER_MATRIX. Remove it from README and docs/frameworks.md to match the actual CLI capabilities. Constraint: Only frameworks in src/schema/constants.ts SDKFrameworkSchema are valid Rejected: Mark CrewAI as "BYO only" | no special BYO treatment exists for it Confidence: high Scope-risk: narrow * chore(deps): override glob to ^13 to silence install deprecation warning (#1008) Co-authored-by: minorun365 * fix: address formatting failure in docs (#1080) * test: remove http-gateway-targets e2e test (#1090) HTTP gateways are created through the AB test flow (add ab-test --mode target-based), not via agentcore add gateway. The standalone test was using MCP gateway commands against placeholder URLs that caused AWS::BedrockAgentCore::GatewayTarget CREATE_FAILED errors. HTTP gateway coverage is provided by ab-test-target-based.test.ts. Co-authored-by: Claude Sonnet 4.6 (1M context) * fix: sync e2e IAM policy and fix run eval flag (#1092) * fix: align E2E batch eval and recommendation tests with current API (#1093) * fix: use correct resourceType for config bundle in E2E status test (#1094) The status command outputs resourceType as 'config-bundle' (hyphenated) but the test was checking for 'configBundle' (camelCase). * docs: clarify integration vs e2e test boundaries and add e2e README (#1111) * docs: clarify integration vs e2e test boundaries and add e2e README - Rewrite integ-tests/README.md to accurately describe what integration tests do (local file/stdout assertions, no AWS required) — the old README described e2e behavior (CloudFormation, credentials, costs) - Add e2e-tests/README.md documenting the AWS boundary, prerequisites, createE2ESuite() pattern, key patterns, and cleanup requirements - Update docs/TESTING.md to add an E2E Tests section with a link to the new README, fix the stale integration tests section that incorrectly listed AWS credentials as a prerequisite, and add link to integ README * chore: fix prettier formatting in docs and READMEs * feat: add archive command for batch evaluations and recommendations (#1112) * feat: add archive command for batch evaluations and recommendations. Introduces a new top-level archive command with two subcommands: archive batch-evaluation and archive recommendation. Each calls the corresponding service delete API and removes the matching local .cli/ history file. * fix: prefix HTTP gateway names with project name to prevent cross-project collisions (#1105) * test: collapse schema enumeration tests and remove duplicates (#1087) * test: collapse schema enumeration tests and remove duplicate help text tests Collapse it.each per-value enumeration patterns into consolidated tests across 9 schema test files. Each enum value was tested individually (e.g., 4 separate 'accepts SEMANTIC', 'accepts SUMMARIZATION' tests) -- now represented by 1-2 tests with representative values. Remove deploy/dev --help unit tests (covered by integ-tests/help.test.ts) and 5 it.todo placeholders from credential-ops.test.ts. Schema tests: ~506 -> ~415. All cross-field validation, superRefine, boundary, and discriminated union tests preserved. * fix: address review comments — restore help tests and add .options assertions - Restore deploy --help and dev --help flag-level assertions (integ smoke test only checks exit code and "Usage:", not specific flags) - Restore --invoke negative regression guard in dev.test.ts - Replace representative-sample enum tests with .options assertions for AgentCoreRegionSchema and GatewayTargetTypeSchema (catches accidental removal of enum values) * fix: set iamRoleFallback to true for lambda gateway targets (#1086) * fix: set iamRoleFallback to true for lambda gateway targets Keep TARGET_TYPE_AUTH_CONFIG in sync with @aws/agentcore-cdk — lambda targets need GATEWAY_IAM_ROLE just like lambdaFunctionArn targets. Related: https://github.com/aws/agentcore-cli/issues/1005 * chore: trigger CI re-run * style: fix prettier table alignment in docs/frameworks.md * fix: correct AB test execution role IAM policy and promote stability (#1120) * fix: add GetEvaluator permission to AB test execution role The AB test API validates evaluator ARNs server-side using the execution role. The auto-created role policy was missing bedrock-agentcore:GetEvaluator, causing a 400 "Access denied when validating evaluator" error on every deploy that included a target-based AB test with a custom evaluator. * fix: correct status assertion in target-based AB test e2e HTTP gateways are not surfaced as top-level resources in agentcore status — they are only used internally to build AB test invocation URLs. The test was asserting on resourceType 'http-gateway' which never appears; fix it to assert on the 'ab-test' resource instead. * fix: add invocationUrl to status resource type cast in e2e test The resource cast was missing invocationUrl, causing a TypeScript error when asserting the AB test's gateway invocation URL was present. * fix: improve promote error message and add local e2e run script - Include stdout in promote failure message so the JSON error is visible - Add scripts/run-e2e-local.sh to replicate the GitHub Actions e2e workflow locally * fix: retry promote stop on 409 UPDATING status When promote is called immediately after resume, the AB test may still be in UPDATING state and reject the STOPPED transition with a 409. Retry up to 6 times with a 10s delay to wait for the transition to complete. * fix: wait for AB test to leave UPDATING state before promoting Poll getABTest until executionStatus is no longer UPDATING before attempting to stop. The service rejects updates with 409 while a state transition is in progress (e.g. after resume). * fix: wait for RUNNING before stopping AB test in promote Poll getABTest until executionStatus === 'RUNNING' before issuing the STOPPED transition. The service 409s if the test is still UPDATING (e.g. transitioning from a prior resume). * fix: resolve evaluator ARNs transitively from AB test online eval configs Previously GetEvaluator was scoped to all project evaluators. Now it's scoped to only the evaluators referenced by the specific online eval configs this AB test uses, handling all three cases: - Custom evaluators: looked up from deployedResources.evaluators - Builtin.* evaluators: ARN constructed from the builtin ID - External ARN references: used as-is * fix: align AB test execution role policy with official docs Replace the hand-rolled per-resource policy with the canonical policy from https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/ab-testing-prereqs.html: - Trust policy: add SourceAccount + SourceArn conditions - Permissions: single AgentCoreResources statement scoped to arn:aws:bedrock-agentcore:*:${accountId}:* with ResourceAccount condition - Add missing actions: GetGatewayTarget, ListGatewayTargets, ListConfigurationBundleVersions - CloudWatch logs scoped to account via PrincipalAccount pattern - Remove per-evaluator/per-resource ARN tracking (no longer needed) * test: update IAM role unit tests to match new policy structure * fix: throw error if AB test never reaches RUNNING before promote * test: add unit tests for promote polling logic and extract to promote-utils Extract waitForRunningThenStop into promote-utils.ts so it can be unit tested without pulling in React/ink. Add 4 tests covering: immediate RUNNING, polling until RUNNING, timeout throws, and error message content. * fix: split DescribeLogGroups into wildcard resource statement * test: verify AB test reaches RUNNING status after deploy in both e2e suites * fix: remove console.log statements that leaked ARNs and fix bundle ARN/version format in config-bundle e2e test * test: remove second deploy and RUNNING check from config-bundle e2e (follow-up with real bundles) --------- Co-authored-by: Local E2E * chore: bump version to 0.13.1 (#1129) Co-authored-by: github-actions[bot] * fix: pin a2a-sdk below 1.0 to prevent breaking changes The a2a-python SDK recently released 1.0 with breaking protocol changes (0.3 spec -> 1.0 spec). Our A2A templates use 0.3-era APIs (a2a.server, a2a.types, a2a.utils) which are incompatible with the 1.0 release. Add an upper bound (< 1.0.0) to all three A2A template pyproject.toml files so pip resolves to 0.3.26 (latest compatible) instead of 1.0.x. * chore(deps): bump @opentelemetry/exporter-metrics-otlp-http (#1141) Bumps [@opentelemetry/exporter-metrics-otlp-http](https://github.com/open-telemetry/opentelemetry-js) from 0.214.0 to 0.217.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/experimental/v0.214.0...experimental/v0.217.0) --- updated-dependencies: - dependency-name: "@opentelemetry/exporter-metrics-otlp-http" dependency-version: 0.217.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump react from 19.2.5 to 19.2.6 (#1136) Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.5 to 19.2.6. - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react) --- updated-dependencies: - dependency-name: react dependency-version: 19.2.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(deploy): pass stack selection to diff and deploy for --target filtering (#980) (#1148) * feat(harness): add verdict prefix to reviewer comments (#1153) * feat(harness): add verdict prefix requirement to reviewer system prompt The PR reviewer agent now must begin every comment with APPROVED, APPROVED WITH MINOR COMMENTS, or REQUESTING CHANGES so outcomes are immediately visible and parseable. * feat(harness): use gh pr review for formal review verdicts Instead of plain comments with text prefixes, the reviewer agent now submits formal GitHub PR reviews via `gh pr review` with --approve, --request-changes, or --comment. This integrates with branch protection rules and makes the review status machine-readable. * refactor(harness): move review verdict to review.md as one-liner Keep system.md as pure workspace context. The review task prompt now ends with a single directive to submit a formal PR review rather than a plain comment. * Update PR review submission instructions Clarified instructions for submitting PR reviews. * fix: resolve sync conflicts from public/main merge - review.md: take public/main's formal PR review approach - package.json: bump @opentelemetry/exporter-metrics-otlp-http to ^0.217.0 - actions.ts: add toStackName import needed for stack name resolution - package-lock.json: regenerated from resolved package.json --------- Signed-off-by: dependabot[bot] Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Co-authored-by: Trirmadura J Ariyawansa Co-authored-by: Avi Alpert <131792194+avi-alpert@users.noreply.github.com> Co-authored-by: Gitika <53349492+notgitika@users.noreply.github.com> Co-authored-by: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Co-authored-by: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Imran Ismail Co-authored-by: Claude Opus 4.6 Co-authored-by: Jesse Turner Co-authored-by: Avi Alpert Co-authored-by: Aidan Daly Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Álvaro <159990212+alvarog2491@users.noreply.github.com> Co-authored-by: Yasuhiro Horiuchi Co-authored-by: kashinoki38 <21358299+kashinoki38@users.noreply.github.com> Co-authored-by: padmak30 Co-authored-by: padmak30 Co-authored-by: Minoru Onda(みのるん) <74597894+minorun365@users.noreply.github.com> Co-authored-by: minorun365 Co-authored-by: Local E2E --- .github/harness/prompts/review.md | 4 +- package-lock.json | 288 ++++++++---------- package.json | 2 +- .../assets.snapshot.test.ts.snap | 6 +- .../python/a2a/googleadk/base/pyproject.toml | 2 +- .../langchain_langgraph/base/pyproject.toml | 2 +- .../python/a2a/strands/base/pyproject.toml | 2 +- .../commands/deploy/__tests__/deploy.test.ts | 36 +++ src/cli/commands/deploy/actions.ts | 59 ++-- 9 files changed, 206 insertions(+), 195 deletions(-) diff --git a/.github/harness/prompts/review.md b/.github/harness/prompts/review.md index d34c67b95..67cb54fec 100644 --- a/.github/harness/prompts/review.md +++ b/.github/harness/prompts/review.md @@ -14,5 +14,5 @@ Review the PR. If there are any serious issues that require code changes before each issue explaining the problem. If there are multiple ways to fix an issue, list the options so the author can choose. Skip style nits and minor suggestions — only flag things that actually need to change. -If all serious issues have already been raised in existing comments, or if you found no new issues, post a single -comment on the PR saying it looks good to merge (or that all issues have already been flagged). +When finished, submit a formal PR review (approve or request changes) with individual and inline comments in it. Be +specific with line numbers. diff --git a/package-lock.json b/package-lock.json index a1bfba552..c789baec9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@aws/agent-inspector": "0.3.0", "@commander-js/extra-typings": "^14.0.0", "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.217.0", "@opentelemetry/otlp-transformer": "^0.213.0", "@opentelemetry/resources": "^2.6.1", "@opentelemetry/sdk-metrics": "^2.6.1", @@ -4069,9 +4069,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4084,16 +4084,16 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.214.0.tgz", - "integrity": "sha512-Tx/59RmjBgkXJ3qnsD04rpDrVWL53LU/czpgLJh+Ab98nAroe91I7vZ3uGN9mxwPS0jsZEnmqmHygVwB2vRMlA==", + "version": "0.217.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.217.0.tgz", + "integrity": "sha512-1zkMzzhiNJdVmLxuwkltqWGw4fOOam47bqRxmuQNjyKJe/9NmY5cIrZ4kiQV7sVGxoOgT0ZvGUfLcjvtpC/b9Q==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-metrics": "2.6.1" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.217.0", + "@opentelemetry/otlp-transformer": "0.217.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-metrics": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4103,9 +4103,9 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "version": "0.217.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.217.0.tgz", + "integrity": "sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -4115,18 +4115,18 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz", - "integrity": "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==", + "version": "0.217.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.217.0.tgz", + "integrity": "sha512-MKK8UHKFUOGAvbZRWh90MhwHG+Fxm6OROBdjKPCF+HQobjuJ/Kuf8Chs8CR45X1aqotxrMj7OxTdsXe8sXuGVA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-logs": "0.214.0", - "@opentelemetry/sdk-metrics": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1", - "protobufjs": "^7.0.0" + "@opentelemetry/api-logs": "0.217.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.217.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1", + "protobufjs": "8.0.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4135,31 +4135,15 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", - "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "version": "0.217.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.217.0.tgz", + "integrity": "sha512-BB+PcHItcZDL63dPMW+mJvwN9rk37wuIDjRxbVlg6pPDvDR/7GL7UJHbGsllgoggOoTimsKgENaWPoGch/oE1A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/api-logs": "0.217.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4169,47 +4153,55 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", - "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", - "license": "Apache-2.0", + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/protobufjs": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.1.tgz", + "integrity": "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w==", + "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/semantic-conventions": "^1.29.0" + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": ">=12.0.0" } }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.214.0.tgz", - "integrity": "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==", + "version": "0.217.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.217.0.tgz", + "integrity": "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-transformer": "0.214.0" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-transformer": "0.217.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4219,9 +4211,9 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "version": "0.217.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.217.0.tgz", + "integrity": "sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -4231,18 +4223,18 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz", - "integrity": "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==", + "version": "0.217.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.217.0.tgz", + "integrity": "sha512-MKK8UHKFUOGAvbZRWh90MhwHG+Fxm6OROBdjKPCF+HQobjuJ/Kuf8Chs8CR45X1aqotxrMj7OxTdsXe8sXuGVA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-logs": "0.214.0", - "@opentelemetry/sdk-metrics": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1", - "protobufjs": "^7.0.0" + "@opentelemetry/api-logs": "0.217.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.217.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1", + "protobufjs": "8.0.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4251,31 +4243,15 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", - "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "version": "0.217.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.217.0.tgz", + "integrity": "sha512-BB+PcHItcZDL63dPMW+mJvwN9rk37wuIDjRxbVlg6pPDvDR/7GL7UJHbGsllgoggOoTimsKgENaWPoGch/oE1A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/api-logs": "0.217.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4285,37 +4261,45 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", - "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", - "license": "Apache-2.0", + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/protobufjs": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.1.tgz", + "integrity": "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w==", + "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/semantic-conventions": "^1.29.0" + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": ">=12.0.0" } }, "node_modules/@opentelemetry/otlp-transformer": { @@ -4387,12 +4371,12 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.0.tgz", - "integrity": "sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.0", + "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4402,21 +4386,6 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", - "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/sdk-logs": { "version": "0.213.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz", @@ -4467,13 +4436,13 @@ } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.0.tgz", - "integrity": "sha512-Vd7h95av/LYRsAVN7wbprvvJnHkq7swMXAo7Uad0Uxf9jl6NSReLa0JNivrcc5BVIx/vl2t+cgdVQQbnVhsR9w==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.0", - "@opentelemetry/resources": "2.7.0" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4482,21 +4451,6 @@ "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", - "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/sdk-trace-base": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", @@ -13911,9 +13865,9 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 4abd52056..004e2dc29 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "@aws/agent-inspector": "0.3.0", "@commander-js/extra-typings": "^14.0.0", "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.217.0", "@opentelemetry/otlp-transformer": "^0.213.0", "@opentelemetry/resources": "^2.6.1", "@opentelemetry/sdk-metrics": "^2.6.1", diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 0962b1b0e..a97bbeb1d 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -1164,7 +1164,7 @@ description = "AgentCore A2A Agent using Google ADK" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "a2a-sdk >= 0.2.0", + "a2a-sdk >= 0.2.0, < 1.0.0", "aws-opentelemetry-distro", "bedrock-agentcore[a2a] >= 1.0.3", "google-adk >= 1.0.0", @@ -1525,7 +1525,7 @@ description = "AgentCore A2A Agent using LangChain + LangGraph" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "a2a-sdk >= 0.2.0", + "a2a-sdk >= 0.2.0, < 1.0.0", {{#if (eq modelProvider "Anthropic")}}"langchain-anthropic >= 0.3.0", {{/if}}{{#if (eq modelProvider "Bedrock")}}"langchain-aws >= 0.2.0", {{/if}}{{#if (eq modelProvider "Gemini")}}"langchain-google-genai >= 2.0.0", @@ -1869,7 +1869,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ {{#if (eq modelProvider "Anthropic")}}"anthropic >= 0.30.0", - {{/if}}"a2a-sdk[all] >= 0.2.0", + {{/if}}"a2a-sdk[all] >= 0.2.0, < 1.0.0", "aws-opentelemetry-distro", "bedrock-agentcore[a2a] >= 1.0.3", "botocore[crt] >= 1.35.0", diff --git a/src/assets/python/a2a/googleadk/base/pyproject.toml b/src/assets/python/a2a/googleadk/base/pyproject.toml index 7fcb75167..426150722 100644 --- a/src/assets/python/a2a/googleadk/base/pyproject.toml +++ b/src/assets/python/a2a/googleadk/base/pyproject.toml @@ -9,7 +9,7 @@ description = "AgentCore A2A Agent using Google ADK" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "a2a-sdk >= 0.2.0", + "a2a-sdk >= 0.2.0, < 1.0.0", "aws-opentelemetry-distro", "bedrock-agentcore[a2a] >= 1.0.3", "google-adk >= 1.0.0", diff --git a/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml b/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml index ef0715758..c906639ed 100644 --- a/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml @@ -9,7 +9,7 @@ description = "AgentCore A2A Agent using LangChain + LangGraph" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "a2a-sdk >= 0.2.0", + "a2a-sdk >= 0.2.0, < 1.0.0", {{#if (eq modelProvider "Anthropic")}}"langchain-anthropic >= 0.3.0", {{/if}}{{#if (eq modelProvider "Bedrock")}}"langchain-aws >= 0.2.0", {{/if}}{{#if (eq modelProvider "Gemini")}}"langchain-google-genai >= 2.0.0", diff --git a/src/assets/python/a2a/strands/base/pyproject.toml b/src/assets/python/a2a/strands/base/pyproject.toml index e062c5564..37f69c01a 100644 --- a/src/assets/python/a2a/strands/base/pyproject.toml +++ b/src/assets/python/a2a/strands/base/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ {{#if (eq modelProvider "Anthropic")}}"anthropic >= 0.30.0", - {{/if}}"a2a-sdk[all] >= 0.2.0", + {{/if}}"a2a-sdk[all] >= 0.2.0, < 1.0.0", "aws-opentelemetry-distro", "bedrock-agentcore[a2a] >= 1.0.3", "botocore[crt] >= 1.35.0", diff --git a/src/cli/commands/deploy/__tests__/deploy.test.ts b/src/cli/commands/deploy/__tests__/deploy.test.ts index ea05fe684..f11fa93b7 100644 --- a/src/cli/commands/deploy/__tests__/deploy.test.ts +++ b/src/cli/commands/deploy/__tests__/deploy.test.ts @@ -1,4 +1,6 @@ import { runCLI } from '../../../../test-utils/index.js'; +import { runDeploy, runDiff } from '../actions.js'; +import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; import { randomUUID } from 'node:crypto'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -56,3 +58,37 @@ describe('deploy without agents', () => { expect(json.error.toLowerCase()).toContain('no resources defined'); }); }); + +describe('runDiff', () => { + it('passes stack selection pattern to toolkit wrapper diff', async () => { + let captured: unknown; + const fakeWrapper = { + diff: (opts?: unknown) => { + captured = opts; + }, + }; + + await runDiff(fakeWrapper as any, 'AgentCore-myapp-prod'); + + expect(captured).toEqual({ + stacks: { strategy: StackSelectionStrategy.PATTERN_MUST_MATCH, patterns: ['AgentCore-myapp-prod'] }, + }); + }); +}); + +describe('runDeploy', () => { + it('passes stack selection pattern to toolkit wrapper deploy', async () => { + let captured: unknown; + const fakeWrapper = { + deploy: (opts?: unknown) => { + captured = opts; + }, + }; + + await runDeploy(fakeWrapper as any, 'AgentCore-myapp-prod'); + + expect(captured).toEqual({ + stacks: { strategy: StackSelectionStrategy.PATTERN_MUST_MATCH, patterns: ['AgentCore-myapp-prod'] }, + }); + }); +}); diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index acae6fb03..0669dff44 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -2,7 +2,8 @@ import { ConfigIO, SecureCredentials } from '../../../lib'; import type { AgentCoreMcpSpec, DeployedState } from '../../../schema'; import { applyTargetRegionToEnv } from '../../aws'; import { validateAwsCredentials } from '../../aws/account'; -import { createSwitchableIoHost } from '../../cdk/toolkit-lib'; +import { CdkToolkitWrapper, createSwitchableIoHost } from '../../cdk/toolkit-lib'; +import type { SwitchableIoHost } from '../../cdk/toolkit-lib'; import { buildDeployedState, getStackOutputs, @@ -40,7 +41,9 @@ import { } from '../../operations/deploy/post-deploy-config-bundles'; import { setupHttpGateways } from '../../operations/deploy/post-deploy-http-gateways'; import { enableOnlineEvalConfigs } from '../../operations/deploy/post-deploy-online-evals'; +import { toStackName } from '../import/import-utils'; import type { DeployResult } from './types'; +import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; export interface ValidatedDeployOptions { target: string; @@ -55,6 +58,38 @@ export interface ValidatedDeployOptions { const AGENT_NEXT_STEPS = ['agentcore invoke', 'agentcore status']; const MEMORY_ONLY_NEXT_STEPS = ['agentcore add agent', 'agentcore status']; +export async function runDiff( + toolkitWrapper: CdkToolkitWrapper, + stackName: string, + switchableIoHost?: SwitchableIoHost +): Promise { + const diffIoHost = switchableIoHost ?? createSwitchableIoHost(); + let hasDiffContent = false; + diffIoHost.setOnRawMessage((code, _level, message) => { + if (!message) return; + // I4002: formatted diff per stack, I4001: overall diff summary + if (code === 'CDK_TOOLKIT_I4002' || code === 'CDK_TOOLKIT_I4001') { + hasDiffContent = true; + console.log(message); + } + }); + diffIoHost.setVerbose(true); + await toolkitWrapper.diff({ + stacks: { strategy: StackSelectionStrategy.PATTERN_MUST_MATCH, patterns: [stackName] }, + }); + if (!hasDiffContent) { + console.log('No stack differences detected.'); + } + diffIoHost.setVerbose(false); + diffIoHost.setOnRawMessage(null); +} + +export async function runDeploy(toolkitWrapper: CdkToolkitWrapper, stackName: string): Promise { + await toolkitWrapper.deploy({ + stacks: { strategy: StackSelectionStrategy.PATTERN_MUST_MATCH, patterns: [stackName] }, + }); +} + export async function handleDeploy(options: ValidatedDeployOptions): Promise { let toolkitWrapper = null; let restoreEnv: (() => void) | null = null; @@ -247,6 +282,8 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { - if (!message) return; - // I4002: formatted diff per stack, I4001: overall diff summary - if (code === 'CDK_TOOLKIT_I4002' || code === 'CDK_TOOLKIT_I4001') { - hasDiffContent = true; - console.log(message); - } - }); - diffIoHost.setVerbose(true); - await toolkitWrapper.diff(); - if (!hasDiffContent) { - console.log('No stack differences detected.'); - } - diffIoHost.setVerbose(false); - diffIoHost.setOnRawMessage(null); + await runDiff(toolkitWrapper, targetStackName, switchableIoHost); endStep('success'); logger.finalize(true); @@ -339,7 +360,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise Date: Mon, 11 May 2026 15:40:25 -0400 Subject: [PATCH 11/27] chore: sync main with public/main (2026-05-11) (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add GitHub Action for automated PR review via AgentCore Harness (#934) * feat: add GitHub Action for automated PR review via AgentCore Harness Adds a workflow that reviews PRs using Bedrock AgentCore Harness. The harness runs an AI agent in an isolated microVM with gh, git, and pre-cloned repos that fetches PR diffs and posts review comments. Workflow: - Triggers on PR open/reopen for agentcore-cli-devs team members - Supports manual workflow_dispatch for any PR URL - Adds/removes ai-reviewing label during review - Authenticates via GitHub OIDC to assume AWS role Files: - .github/workflows/pr-ai-review.yml — main workflow - .github/scripts/python/harness_review.py — harness invocation script - .github/scripts/python/harness_config.py — config from env vars - .github/scripts/models/ — local boto3 service model (InvokeHarness not yet in standard boto3) Required secrets: - HARNESS_AWS_ROLE_ARN — IAM role ARN for OIDC - HARNESS_ACCOUNT_ID — AWS account ID - HARNESS_ID — Harness ID * refactor: replace local service model with raw HTTP + SigV4 signing Eliminates the 220KB bundled service model by using direct HTTP requests with SigV4 authentication to invoke the harness endpoint. No extra dependencies needed — urllib3, SigV4Auth, and EventStreamBuffer are all part of botocore/boto3. Rejected: invoke_agent_runtime API | server rejects harness ARNs with ResourceNotFoundException Confidence: high Scope-risk: moderate * refactor: inline harness config into review script Remove separate harness_config.py — env vars are read directly in harness_review.py. One less file to maintain, config is still driven entirely by environment variables set in the GitHub workflow. * refactor: extract invoke_harness helper for cleaner main flow * refactor: simplify config and improve script readability - Replace HARNESS_ACCOUNT_ID + HARNESS_ID with single HARNESS_ARN env var - Extract prompts into separate .md files in .github/scripts/prompts/ - Extract stream parsing into print_stream() function - Add close_group() helper to deduplicate ::group:: bookkeeping * refactor: separate event parsing from display logic Extract parse_events() generator to handle binary stream decoding, keeping print_stream() focused on formatting and log groups. * docs: add explanatory comments to harness review functions * refactor: derive region from HARNESS_ARN instead of separate env var Eliminates HARNESS_REGION env var — the region is extracted from the ARN directly, so there's no risk of a mismatch causing confusing SigV4 auth errors. * chore: rename label to agentcore-harness-reviewing * refactor: move auth check to job level so entire review is skipped early Split into authorize + ai-review jobs. The ai-review job only runs if the PR author is authorized (team member or write access) or if triggered via workflow_dispatch. Removes repeated if conditions from every step. * chore: exclude AI prompt templates from prettier Prompt markdown files use intentional formatting that prettier would reflow, breaking the prompt structure. * fix: buffer streaming text to avoid per-token log lines in GitHub Actions (#946) Each text delta from the harness was printed individually with flush, creating a separate log line per token. Now text is buffered and flushed as complete lines at block boundaries. * fix: allow code-based evaluators in online eval configs (#947) * fix: allow code-based evaluators in online eval configs Remove restrictions that blocked code-based evaluators from being used in online evaluation configs. The service now supports code-based evaluators for online evaluation. Changes: - Remove code-based evaluator block in OnlineEvalConfigPrimitive - Remove code-based evaluator validation in schema superRefine - Remove code-based evaluator filter in TUI evaluator picker * style: fix prettier formatting * fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs (#949) * fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs When commands are invoked without flags in non-interactive environments (CI, piped stdin, agent automation), the CLI falls through to Ink TUI rendering which hangs indefinitely. Add a requireTTY() guard at every TUI entry point that checks process.stdout.isTTY and exits with a helpful error message directing users to --help for non-interactive flags. Closes #685 * fix: check both stdin and stdout isTTY in requireTTY guard The hang from #685 is caused by stdin not being a TTY (Ink reads keyboard input from stdin), not stdout. Check both stdin and stdout so the guard fires for piped stdin, redirected stdout, and CI environments where both are non-TTY. * fix: agentcore dev not working in windows (#951) * fix: use pull_request_target for fork PR support (#958) * fix: make label step non-blocking for fork PRs Fork PRs get read-only GITHUB_TOKEN regardless of workflow permissions, causing the addLabels API call to fail with 403. This crashed the entire job before the review could run. continue-on-error lets the review proceed even when labeling fails. * fix: use pull_request_target for full write access on fork PRs pull_request gives a read-only GITHUB_TOKEN for fork PRs, preventing labels and secrets from working. pull_request_target runs in the base repo context with full permissions. This is safe because we never check out or execute fork code — the harness fetches the PR diff via the GitHub API. * fix: lower eventExpiryDuration minimum from 7 to 3 days (closes #744) (#956) The AWS CreateMemory API allows a minimum of 3 days, but the CLI schema was rejecting values below 7. Update the Zod schema, LLM compacted types, import clamping logic, and all related tests. * fix: display session ID after CLI invoke completes (#957) * fix: display session ID after CLI invoke completes (closes #664) The streaming and non-streaming invoke responses include a session ID from the runtime, but the CLI paths discarded it. Now prints the session ID and a resume command hint after invoke output. * fix: include sessionId in AGUI protocol invoke result * test: add browser tests for agent inspector (#938) * feat: add telemetry schemas and client (#941) * chore: bump version to 0.11.0 (#967) Co-authored-by: github-actions[bot] * fix(invoke): auto-generate session ID for bearer-token invocations (#953) Closes #840 When invoking an agent with a bearer token (OAuth/CUSTOM_JWT) and no session ID, `AgentCoreMemoryConfig` raised a Pydantic validation error because `session_id=None` is rejected. Unlike SigV4 callers, bearer-token callers do not get a server-side auto-generated runtime session ID. Two-layer fix: 1. CLI synthesizes a UUID in `invoke` action when `--bearer-token` is set and `--session-id` is missing, using the existing `generateSessionId` helper. Covers both explicit `--bearer-token` and the CUSTOM_JWT auto-fetch path. 2. Strands memory session templates (http, agui, a2a) synthesize a UUID when `session_id` is falsy before constructing AgentCoreMemoryConfig. Protects direct runtime callers (curl, custom apps) who forget the `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` header. Snapshot tests updated. * fix: show 'Computing diff changes...' step during deploy diff phase (#952) The deploy TUI appeared frozen for 5-15 seconds between preflight completion and 'Publish assets' while cdkToolkitWrapper.diff() ran silently with no step marked as running. Add a dedicated pre-deploy diff step that transitions running -> success around the diff call so StepProgress always has something to highlight. Closes #781 * test: split browser tests into its own job, fix logs path (#975) * feat(invoke): add --prompt-file and stdin support for long prompts (#974) * feat(invoke): add --prompt-file and stdin support for long prompts Long prompts hit shell argument limits (E2BIG, typically 128KB-2MB) when passed as positional args. This adds two new sources: - --prompt-file : read prompt from a file - piped stdin: when no prompt is given and stdin is not a TTY, read the prompt from stdin Precedence is hybrid and backward-compatible: --prompt > positional > --prompt-file > stdin --prompt-file combined with piped stdin content returns an explicit collision error rather than silently picking one. Closes #686 * docs(invoke): document --prompt-file and stdin support * fix(import): remove experimental warning from import command (#977) The import feature has stabilized and no longer needs the experimental label. * fix: duplicate header flash and help menu truncation (closes #895, closes #637) (#955) - Return null during brief transitional phases to prevent Ink from rendering a header that gets immediately replaced by a different frame - Consolidate CreateScreen phases into a single Screen mount - Make help menu description width responsive to terminal size - Remove hardcoded 50-char description truncation limit * test: configure git in browser tests workflow (#976) * feat: add project-name option to create (#969) * Add project-name option to create * fix: address review feedback — restore name description and move backfill logic * ci: bump the github-actions group across 1 directory with 4 updates (#964) Bumps the github-actions group with 4 updates in the / directory: [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials), [actions/github-script](https://github.com/actions/github-script), [softprops/action-gh-release](https://github.com/softprops/action-gh-release) and [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action). Updates `aws-actions/configure-aws-credentials` from 5 to 6 - [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/v5...v6) Updates `actions/github-script` from 8 to 9 - [Commits](https://github.com/actions/github-script/compare/v8...v9) Updates `softprops/action-gh-release` from 2 to 3 - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) Updates `slackapi/slack-github-action` from 3.0.1 to 3.0.2 - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Changelog](https://github.com/slackapi/slack-github-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/slackapi/slack-github-action/compare/v3.0.1...v3.0.2) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: aws-actions/configure-aws-credentials dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: slackapi/slack-github-action dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump aws-cdk-lib (#962) Bumps the aws-cdk group with 1 update in the / directory: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib). Updates `aws-cdk-lib` from 2.248.0 to 2.250.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.250.0/packages/aws-cdk-lib) --- updated-dependencies: - dependency-name: aws-cdk-lib dependency-version: 2.250.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump postcss from 8.5.8 to 8.5.10 (#961) Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.10. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.10 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump secretlint from 11.4.1 to 12.2.0 (#916) Bumps [secretlint](https://github.com/secretlint/secretlint) from 11.4.1 to 12.2.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v11.4.1...v12.2.0) --- updated-dependencies: - dependency-name: secretlint dependency-version: 12.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @vitest/coverage-v8 from 4.1.2 to 4.1.5 (#915) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.2 to 4.1.5. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @secretlint/secretlint-rule-preset-recommend (#914) Bumps [@secretlint/secretlint-rule-preset-recommend](https://github.com/secretlint/secretlint) from 11.4.1 to 12.2.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v11.4.1...v12.2.0) --- updated-dependencies: - dependency-name: "@secretlint/secretlint-rule-preset-recommend" dependency-version: 12.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the aws-sdk group across 1 directory with 14 updates (#912) Bumps the aws-sdk group with 14 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@aws-sdk/client-application-signals](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-application-signals) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agentcore](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agentcore-control](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore-control) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cloudformation](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudformation) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cloudwatch-logs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudwatch-logs) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-resource-groups-tagging-api](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-resource-groups-tagging-api) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-xray](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-xray) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/credential-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/credential-providers) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cognito-identity-provider](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cognito-identity-provider) | `3.1036.0` | `3.1037.0` | Updates `@aws-sdk/client-application-signals` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-application-signals/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-application-signals) Updates `@aws-sdk/client-bedrock` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock) Updates `@aws-sdk/client-bedrock-agent` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agent) Updates `@aws-sdk/client-bedrock-agentcore` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agentcore) Updates `@aws-sdk/client-bedrock-agentcore-control` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore-control/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agentcore-control) Updates `@aws-sdk/client-bedrock-runtime` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-runtime) Updates `@aws-sdk/client-cloudformation` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudformation/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cloudformation) Updates `@aws-sdk/client-cloudwatch-logs` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudwatch-logs/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cloudwatch-logs) Updates `@aws-sdk/client-resource-groups-tagging-api` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-resource-groups-tagging-api/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-resource-groups-tagging-api) Updates `@aws-sdk/client-s3` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-s3) Updates `@aws-sdk/client-sts` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-sts) Updates `@aws-sdk/client-xray` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-xray/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-xray) Updates `@aws-sdk/credential-providers` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/credential-providers/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/packages/credential-providers) Updates `@aws-sdk/client-cognito-identity-provider` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cognito-identity-provider/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cognito-identity-provider) --- updated-dependencies: - dependency-name: "@aws-sdk/client-application-signals" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agent" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore-control" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-runtime" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudformation" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudwatch-logs" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cognito-identity-provider" dependency-version: 3.1034.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-resource-groups-tagging-api" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-xray" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/credential-providers" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump hono from 4.12.12 to 4.12.14 (#868) Bumps [hono](https://github.com/honojs/hono) from 4.12.12 to 4.12.14. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.12...v4.12.14) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.14 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump esbuild from 0.27.4 to 0.28.0 (#862) Bumps [esbuild](https://github.com/evanw/esbuild) from 0.27.4 to 0.28.0. - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.27.4...v0.28.0) --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.28.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * test: speed up CI and fix mock cleanup gaps (#989) * test: speed up CI and fix mock cleanup gaps - Node 20 only on PRs (full matrix on main) - 3-way vitest sharding for unit tests with blob report merging - Pre-bundle heavy deps (AWS SDK, Smithy, zod, commander) via deps.optimizer - Exclude tui-harness from unit test project (not production code) - Add afterEach(vi.restoreAllMocks) to 3 files with mock cleanup gaps - Move inline consoleSpy.mockRestore() to afterEach in logs-eval tests - Skip PTY tests when node-pty spawn is unavailable * style: fix prettier formatting in build-and-test.yml * fix: enable include-hidden-files for blob artifact upload upload-artifact@v7 defaults include-hidden-files to false, which skips the .vitest-reports directory. Also fail loudly if no files found. * feat: runtime endpoint support in AgentCore CLI (#979) * feat: add runtime endpoint support to AgentCore CLI - Schema: endpoints field on AgentEnvSpec, runtimeVersion in deployed state - Primitive: RuntimeEndpointPrimitive with add/remove/preview - TUI: Add and Remove flows with multi-field form - Status: endpoints nested under agents with deployment badges - Deploy: parseRuntimeEndpointOutputs + buildDeployedState pipeline * fix: correct output key prefix for runtime endpoint parsing The CFN output keys include the AgentEnvironment construct prefix (Agent{PascalName}) which was missing from the parser pattern. * fix: remove .omc state files and unused useCallback import - Remove .omc/ from git tracking, add to .gitignore - Remove unused useCallback import in AddRuntimeEndpointScreen.tsx * fix: shorten runtime endpoint description to prevent TUI overflow The description "Named endpoint (version alias) for a runtime" was too long and wrapped to the next line in the Add Resource menu. Shortened to "Named endpoint for a runtime". * fix: validate runtime endpoint version is a positive integer - Add explicit Number.isInteger check before schema validation - Change Commander parser from parseInt to Number so floats like 3.5 are caught instead of silently truncated * fix: use agent/endpoint composite key to prevent React key collision Endpoint names can collide across runtimes (e.g., both have "prod"). Changed React key from epName to agent.name/epName to prevent duplicate key warnings that pollute the TUI viewport. * fix: render runtime endpoints in status --type runtime-endpoint When filtering by --type runtime-endpoint, agents array is empty so the agents section (which nests endpoints) never renders. Added a standalone Runtime Endpoints section that shows when endpoints exist but agents don't (i.e., when type-filtering). * fix: add runtime-endpoint to status --help --type documentation The --type option help text was missing runtime-endpoint from the list of valid resource types. * fix: return richer JSON response from add runtime-endpoint add now returns { success, endpointName, agent, version } instead of sparse { success: true }, matching the richer response shape from remove runtime-endpoint. * fix: validate endpoint version against deployed runtime version - TUI: show "Current deployed version: N" and valid range (1-N) - TUI: reject version exceeding latest deployed version - CLI: check deployed-state.json for max version, reject if exceeded - If runtime not deployed, only positive integer check applies * chore: remove planning and bug bash docs from PR * fix: use composite key and parentName for endpoint identification - Add parentName field to ResourceStatusEntry for structured parent linking - Use runtimeName/endpointName composite key in remove/preview/getRemovable - Status command filters endpoints by parentName instead of parsing detail string - React keys use structured parentName/name instead of display strings * test: add comprehensive unit tests for RuntimeEndpointPrimitive 23 tests covering add(), remove(), previewRemove(), getRemovable(): - Runtime lookup, duplicate detection, version validation - Composite key removal targeting correct runtime - Empty endpoints dict cleanup - Version validation against deployed state - Richer JSON response shape * fix: remove dead findGatewayTargetReferences stub * fix: use BasePrimitive configIO instead of ad-hoc ConfigIO in add() * fix: use Number() instead of parseInt in TUI version validation * chore: fix prettier formatting * fix: use T[] instead of Array to satisfy eslint array-type rule * feat: add gateway import command with executionRoleArn support (#855) * feat: add gateway import command and unhide import from TUI Add `agentcore import gateway --arn ` to import existing AWS gateways (with all targets) into a local CLI project. Also remove import from the HIDDEN_FROM_TUI list so it appears in the interactive TUI. - Add AWS SDK wrappers for gateway/target list/get APIs - Add import-gateway.ts with multi-resource CFN import support - Add resourceName schema field to preserve actual AWS gateway name during import - Register gateway in TUI ImportSelectScreen and ImportProgressScreen - Extend ARN pattern, deployed state, and CFN constants for gateway type * fix: expand ARN input to show full resource ARN and add gateway support The ARN text input was truncating long ARNs. Use the expandable prop to wrap text across multiple lines. Also add gateway to the ARN validation pattern and resource type labels. * refactor: remove --name and --yes flags from import gateway command Remove --name (confusing local rename) and --yes (no prompts to confirm) from the gateway import command. The gateway's AWS name is used directly. * feat: add e2e tests for import gateway command Add end-to-end tests that create a real AWS gateway with an MCP server target, import it via `agentcore import gateway --arn`, and verify the resulting agentcore.json fields and deployed-state.json entries. New files: - e2e-tests/fixtures/import/setup_gateway.py: creates gateway + target - e2e-tests/fixtures/import/common.py: gateway wait helpers - e2e-tests/fixtures/import/cleanup_resources.py: gateway cleanup Constraint: Tests follow the existing import-resources.test.ts pattern Confidence: high Scope-risk: narrow * chore: gitignore bugbash-resources.json and .omc/ * feat: preserve gateway executionRoleArn during import Extract roleArn from the AWS GetGateway response and map it to executionRoleArn in agentcore.json. On deploy, CDK uses iam.Role.fromRoleArn() instead of creating a new role, keeping the original permissions intact. Constraint: imported roles use mutable: false so CDK cannot modify them Rejected: always create new role | breaks permissions on re-import Confidence: high Scope-risk: narrow * refactor: export internal gateway import functions for unit testing Add @internal exports for toGatewayTargetSpec, resolveOutboundAuth, toGatewaySpec, and buildCredentialArnMap to enable direct unit testing of the pure mapping functions in import-gateway.ts. Confidence: high Scope-risk: narrow * test: add unit tests for mcpServer target mapping and credential resolution Bugbash coverage for toGatewayTargetSpec and resolveOutboundAuth: - mcpServer with no auth, OAuth, and API_KEY credentials - Credential resolution warnings when ARNs not in project - Targets with no MCP configuration - OAuth scopes pass-through and empty scopes omission 8 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for apiGateway, openApiSchema, smithyModel, lambda target mapping Bugbash coverage for toGatewayTargetSpec non-mcpServer target types: - apiGateway: restApiId, stage, toolFilters, toolOverrides mapping - openApiSchema: S3 URI mapping, missing URI warning - smithyModel: S3 URI mapping, missing URI warning - lambda: S3 tool schema to lambdaFunctionArn mapping, missing ARN, inline-only schema warning, progress messages - Unrecognized target type warning 13 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for toGatewaySpec gateway-level field mapping Bugbash coverage for toGatewaySpec AWS-to-CLI schema mapping: - Authorizer types: NONE, AWS_IAM, CUSTOM_JWT with all JWT fields - CUSTOM_JWT customClaims with full claim structure - Semantic search: SEMANTIC/KEYWORD/missing protocolConfiguration - Exception level: DEBUG/undefined/other values - Policy engine: ARN name extraction, mode preservation - Optional fields: resourceName, description, tags, executionRoleArn - Edge cases: empty tags object omitted, empty JWT arrays omitted 23 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for handleImportGateway full flow validation Bugbash coverage for the main gateway import flow: - Happy path: successful import with --arn, config written, result verified - Rollback: pipeline failure restores original config, noResources error - Duplicate detection: name collision, resource ID already tracked - Name validation: invalid name regex, --name override preserves resourceName - Auto-select: single gateway auto-selected, multiple gateways error, no gateways error - Target mapping: skipped targets warning, non-READY gateway continues 12 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for buildCredentialArnMap and CFN template matching Bugbash coverage for credential resolution and CFN resource matching: - buildCredentialArnMap: reads ARN-to-name map from deployed state, handles multiple credentials, empty/missing state, thrown errors - findLogicalIdByProperty: gateway by Name property, resourceName fallback, target by Name, Fn::Join/Fn::Sub intrinsic function patterns, regex boundary check prevents false substring matches - findLogicalIdsByType: single gateway fallback, single target fallback, multiple targets prevent fallback 14 tests, all passing. Confidence: high Scope-risk: narrow * fix: exclude already-deployed logical IDs when building import resource list When a project already contains an imported resource (gateway + target, agent, memory, etc.), a subsequent import of a different resource that shares a Name with the deployed one caused buildResourcesToImport to resolve the OLD logical ID via findLogicalIdByProperty. The resulting CFN change set then failed with "Resources [...] passed in ResourceToImport are already in a stack and cannot be imported." Thread the deployed template into every buildResourcesToImport callback and skip logical IDs already present in the stack during both the name lookup and the single-candidate fallback. Constraint: GatewayTarget has no structural parent ref in Properties — only the physical-ID tuple (GatewayIdentifier, TargetId), so scoping the synth search by parent gateway is not available. Rejected: Parse Fn::Ref/Fn::GetAtt from GatewayIdentifier | brittle intrinsic traversal Rejected: Match by physical TargetId | synth template has no physical ID for new resources Rejected: Strip deployed resources from synth before lookup | breaks buildImportTemplate Confidence: high Scope-risk: narrow Directive: new callbacks into executeCdkImportPipeline must accept and honor the deployedTemplate arg Not-tested: multi-region / cross-stack-identifier collisions * fix(import): translate AccessDenied on GetGateway to a friendly not-found error When importing a gateway by a well-formed but nonexistent ARN, the BedrockAgentCore control plane returns AccessDenied (not ResourceNotFound) for bedrock-agentcore:GetGateway. The CLI surfaced the raw SDK error — which is misleading when the caller has full Admin access and the gateway simply doesn't exist. Catch AccessDenied from getGatewayDetail and return a targeted failure with guidance: the gateway is likely nonexistent / the ARN is malformed / the caller lacks GetGateway. Point the user at list-gateways so they can confirm. Constraint: AWS returns AccessDenied instead of ResourceNotFound for nonexistent gateway IDs; we cannot distinguish the two server-side Rejected: Client-side ARN existence probe via ListGateways | extra latency on the happy path and still racy Confidence: high Scope-risk: narrow Directive: Do not swallow other error classes here — only AccessDenied is reinterpreted * fix(import): detect AWS_REGION / ARN region mismatch before import Previously when a user ran import with AWS_REGION=us-west-2 against a us-east-1 ARN, and no deployment targets existed yet, the CLI silently synthesized a default target from the ARN's region and proceeded — so the user would unknowingly import from a different region than they intended, leaving agentcore.json pointed at the wrong region and causing cross-region CFN errors on later deploy. Short-circuit resolveImportTarget when AWS_REGION (or AWS_DEFAULT_REGION) is set and disagrees with the ARN's region, and ask the user to reconcile explicitly. Constraint: Must fail fast before any side effects (writing aws-targets.json, calling GetGateway) Rejected: Warn-and-continue | a silent cross-region import is exactly the failure mode we're preventing Confidence: high Scope-risk: narrow Directive: Only throw when both env region AND ARN region are present — do not require AWS_REGION to be set * fix(import): allow re-import of resource after remove without CDK pipeline After `agentcore remove gateway`, the gateway entry remains in deployed-state.json (correctly, since CFN still manages it) but is removed from agentcore.json. A subsequent `agentcore import gateway` would reject with "already imported" because the dedup check only looked at deployed-state. Now, when a resource exists in deployed-state but not in agentcore.json, the import skips the CDK pipeline and just re-adds the resource to agentcore.json. Applies to both the gateway-specific and generic import orchestrators. * style: fix prettier formatting for import-utils and ArnInputScreen * fix(import): address PR review blockers B4, B6, B7, H2, H5, H7, H8 - B4: Hard-fail when credential provider ARN is not found in deployed state instead of silently dropping outboundAuth - B7: Preserve outboundAuth on lambda→lambdaFunctionArn mapping and allow OAUTH/NONE auth types for lambdaFunctionArn targets - H2: Remove re-import fast path; run full CDK pipeline so out-of-band targets are properly imported. Treat noResources as success for re-imports since all resources are already in the CFN stack - H5: Replace hardcoded arn:aws: with partition-agnostic arn:[^:]+: in ARN validation and region extraction regexes - H7/H8: Add regex validation and max length for executionRoleArn, use GatewayNameSchema for resourceName, add refine ensuring both fields are set together or both omitted * fix(import): remove credential ARN from error messages to resolve CodeQL alert CodeQL flagged clear-text logging of credential provider ARNs. The target name provides sufficient context for the user to identify the issue. * fix(import): remove resourceName/executionRoleArn co-variance refine (#996) The refine required both fields to be set together, but resourceName is always needed on import (to preserve the AWS name) while executionRoleArn is only present when the gateway has a custom role. Gateways without a custom role (service auto-creates one) fail the refine because resourceName is set but executionRoleArn is not. Keep the individual field validations (GatewayNameSchema for resourceName, regex for executionRoleArn). * fix(e2e): separate gateway import test and add PR-changed test detection (#999) Split gateway import e2e tests into their own file so they can run independently with faster setup (only setup_gateway.py instead of all 4 resource scripts). Update the PR e2e workflow to detect changed test files and include them alongside the strands-bedrock baseline, using only the main CDK source to reduce CI time. Constraint: PR workflow must always run strands-bedrock as a baseline Rejected: Keep gateway in combined suite | setup creates unnecessary resources when running gateway-only Confidence: high Scope-risk: narrow * fix(e2e): add debug logging for gateway import CI failures (#1001) * fix(e2e): add debug logging for gateway import failures Print the import log file and CloudFormation stack events when the gateway import test fails to help diagnose IMPORT_ROLLBACK_IN_PROGRESS errors in CI. Confidence: high Scope-risk: narrow * fix(e2e): add shared debug logging for all import test failures Add dumpImportDebugInfo to e2e-helper that prints the import log file and CloudFormation stack events when an import fails. Used by both import-resources and import-gateway tests to diagnose CI failures. Confidence: high Scope-risk: narrow * chore: bump version to 0.12.0 (#1002) Co-authored-by: github-actions[bot] * test: remove 44 render-only and framework-testing tests (#998) * 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. * 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 * feat: add CloudWatch traces API for web UI (#997) * fix: remove CONFIG_DIR exclusion from zip stage to preserve dependency agentcore/ packages (#1015) PR #844 correctly removed the flat name-based agentcore exclusion and threaded rootDir through copySourceTree, but the same CONFIG_DIR check remained in collectFiles/collectFilesSync (the zip stage). Since the zip stage operates on the staging directory — not the project root — the check incorrectly stripped any top-level agentcore/ Python package installed by uv (e.g., langgraph_checkpoint_aws/agentcore/) from the deployment artifact, causing ModuleNotFoundError at runtime. The CONFIG_DIR exclusion is only needed in copySourceTree (which copies from the project root into staging). By the time we zip, the project config dir was already filtered out — the only agentcore/ in staging is a legitimate dependency package. Closes #843 * ci: add coordinated main + preview release workflow (#995) * ci: add coordinated main + preview release workflow Adds a single workflow_dispatch that releases both branches together, ensuring they stay in sync on npm. * fix: address review — bump script compat, pre-publish verification, drop unused artifacts - Preview bump now uses `prerelease --prerelease-tag preview` which the bump-version.ts script actually accepts - Added verify-merges job that checks both main and preview have the expected versions before either publish runs (prevents drift) - Both publish jobs now depend on verify-merges instead of each other, so neither publishes unless both PRs are confirmed merged - Removed upload-artifact steps from test jobs since publish jobs rebuild from source post-merge * fix: auto-rebase preview onto main in preflight step Instead of failing when preview isn't rebased, the workflow now rebases automatically. If there are conflicts, it aborts and directs the user to resolve manually. * ci: add sync-preview workflow, simplify release preflight - New sync-preview.yml: runs on every push to main, auto-rebases preview onto main. Silently skips on conflicts (no failure). - Release workflow preflight reverted to a simple check — relies on sync-preview having already done the rebase. * fix: use merge instead of rebase for preview sync Rebase overwrites preview-specific values (package version, tests). Merge preserves preview's divergent files and only conflicts when both branches touch the same lines. * fix: concurrency control, conflict notifications, CDK tag TODO - Add concurrency group to sync-preview to prevent parallel race - On merge conflict, auto-create a GitHub issue (deduplicated) instead of silently skipping - Add TODO comment for CDK preview dist-tag in prepare-preview * fix: create PR with conflict markers instead of issue on merge conflict On conflict, sync-preview now: - Creates a branch with the merge conflict markers committed - Opens a PR targeting preview with resolution instructions - Tags the original commit author for visibility - Deduplicates (skips if a sync PR is already open) * chore(deps): bump the aws-sdk group with 14 updates (#1024) Bumps the aws-sdk group with 14 updates: | Package | From | To | | --- | --- | --- | | [@aws-sdk/client-application-signals](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-application-signals) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agentcore](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agentcore-control](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore-control) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cloudformation](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudformation) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cloudwatch-logs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudwatch-logs) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-resource-groups-tagging-api](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-resource-groups-tagging-api) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-xray](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-xray) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/credential-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/credential-providers) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cognito-identity-provider](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cognito-identity-provider) | `3.1037.0` | `3.1038.0` | Updates `@aws-sdk/client-application-signals` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-application-signals/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-application-signals) Updates `@aws-sdk/client-bedrock` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock) Updates `@aws-sdk/client-bedrock-agent` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agent) Updates `@aws-sdk/client-bedrock-agentcore` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agentcore) Updates `@aws-sdk/client-bedrock-agentcore-control` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore-control/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agentcore-control) Updates `@aws-sdk/client-bedrock-runtime` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-runtime) Updates `@aws-sdk/client-cloudformation` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudformation/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cloudformation) Updates `@aws-sdk/client-cloudwatch-logs` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudwatch-logs/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cloudwatch-logs) Updates `@aws-sdk/client-resource-groups-tagging-api` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-resource-groups-tagging-api/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-resource-groups-tagging-api) Updates `@aws-sdk/client-s3` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-s3) Updates `@aws-sdk/client-sts` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-sts) Updates `@aws-sdk/client-xray` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-xray/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-xray) Updates `@aws-sdk/credential-providers` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/credential-providers/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/packages/credential-providers) Updates `@aws-sdk/client-cognito-identity-provider` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cognito-identity-provider/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cognito-identity-provider) --- updated-dependencies: - dependency-name: "@aws-sdk/client-application-signals" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agent" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore-control" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-runtime" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudformation" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudwatch-logs" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-resource-groups-tagging-api" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-xray" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/credential-providers" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cognito-identity-provider" dependency-version: 3.1038.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-sdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the aws-cdk group with 2 updates (#1025) Bumps the aws-cdk group with 2 updates: [@aws-cdk/toolkit-lib](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/@aws-cdk/toolkit-lib) and [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib). Updates `@aws-cdk/toolkit-lib` from 1.24.0 to 1.25.0 - [Release notes](https://github.com/aws/aws-cdk-cli/releases) - [Commits](https://github.com/aws/aws-cdk-cli/commits/@aws-cdk/toolkit-lib@v1.25.0/packages/@aws-cdk/toolkit-lib) Updates `aws-cdk-lib` from 2.250.0 to 2.251.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.251.0/packages/aws-cdk-lib) --- updated-dependencies: - dependency-name: "@aws-cdk/toolkit-lib" dependency-version: 1.25.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-cdk - dependency-name: aws-cdk-lib dependency-version: 2.251.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @opentelemetry/resources from 2.6.1 to 2.7.0 (#1026) Bumps [@opentelemetry/resources](https://github.com/open-telemetry/opentelemetry-js) from 2.6.1 to 2.7.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/v2.6.1...v2.7.0) --- updated-dependencies: - dependency-name: "@opentelemetry/resources" dependency-version: 2.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @secretlint/secretlint-rule-preset-recommend (#1028) Bumps [@secretlint/secretlint-rule-preset-recommend](https://github.com/secretlint/secretlint) from 12.2.0 to 12.3.1. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v12.2.0...v12.3.1) --- updated-dependencies: - dependency-name: "@secretlint/secretlint-rule-preset-recommend" dependency-version: 12.3.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump secretlint from 12.2.0 to 12.3.1 (#1029) Bumps [secretlint](https://github.com/secretlint/secretlint) from 12.2.0 to 12.3.1. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v12.2.0...v12.3.1) --- updated-dependencies: - dependency-name: secretlint dependency-version: 12.3.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @opentelemetry/sdk-metrics from 2.6.1 to 2.7.0 (#1030) Bumps [@opentelemetry/sdk-metrics](https://github.com/open-telemetry/opentelemetry-js) from 2.6.1 to 2.7.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/v2.6.1...v2.7.0) --- updated-dependencies: - dependency-name: "@opentelemetry/sdk-metrics" dependency-version: 2.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(ci): update snapshots after CDK version sync in release workflow (#1033) The release workflow syncs @aws/agentcore-cdk to the latest npm version in the asset template, but never updates the snapshot file. This causes the asset snapshot test to fail because the snapshot still holds the old version string. * fix(ci): enable coverage collection in sharded unit test runs (#1034) The coverage report on PRs was empty (0/0 Unknown%) because the sharded unit-test jobs ran without --coverage. Without that flag, V8 coverage data is never collected, so the blob reports contain no coverage maps. The merge-reports step then merges undefined entries and produces empty results. Also fixes the coverage report action referencing a nonexistent vitest.unit.config.ts (should be vitest.config.ts). * fix(ci): move snapshot update after build in release workflow (#1036) Move `npm run test:update-snapshots` from inside the CDK sync step (before build) to its own step after `npm run build`. The test suite needs built artifacts to pass — running it before the build caused 18 test failures. * fix(ci): install uv in release workflow prepare steps (#1038) The create.test.ts tests require uv for Python project scaffolding. The Build and Test workflow installs it via astral-sh/setup-uv, but the release workflow's prepare steps were missing it, causing test failures during the snapshot update step. * chore: bump version to 0.12.1 * fix: remove CDK version auto-sync from release workflow and restore caret range (#1044) Remove the auto-sync step that bumped @aws/agentcore-cdk during releases — version updates will be managed manually via PRs. Restore the caret range (^0.1.0-alpha.19) in the asset and snapshot that was dropped by the auto-sync in #1042. * fix: add Accept header to HTTP protocol invocation proxy (#1051) The dev web UI proxy for HTTP protocol agents doesn't include an Accept header when forwarding requests to /invocations. The bedrock-agentcore runtime SDK checks for Accept: text/event-stream before enabling SSE streaming (via @fastify/sse reply.sse), so streaming handlers always get a 406 response in the inspector. A2A and AGUI protocol paths already send the header correctly — this brings HTTP in line with them. Using "text/event-stream, */*" so streaming agents get SSE enabled while non-streaming agents can respond in whatever format they need. * feat: add telemetry audit mode with FileSystemSink (#1014) * feat: add FilesystemSink for telemetry audit mode * feat: instrument help.modes with telemetry, add audit integ test * refactor: move harness resources to .github/harness/ (#992) * refactor: move harness resources to .github/harness/ Move PR reviewer harness files into a dedicated .github/harness/ directory, separate from the general .github/scripts/ used by Strands workflows. - Move harness_review.py, prompts/ to .github/harness/ - Add Dockerfile for the harness container (dual-token: CLONE_TOKEN for git clones, GITHUB_TOKEN for gh CLI/PR comments) - Add README documenting the harness directory - Update pr-ai-review workflow to reference new path - Update .prettierignore for new prompts location * fix(harness): update Dockerfile comment to accurately describe token handling Tokens are baked into image layers at build time — the previous comment incorrectly implied they were not stored. Updated to make the security posture explicit: the image itself must be treated as a secret. * refactor(harness): use boto3 invoke_harness instead of raw SigV4 HTTP Replace manual SigV4 signing + urllib3 + EventStreamBuffer parsing with the native boto3 bedrock-agentcore client's invoke_harness method. This simplifies the code significantly and leverages the typed event stream response from the SDK. Rejected: keep raw HTTP approach | boto3 now supports invoke_harness natively Confidence: high Scope-risk: narrow Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * Revert "refactor: move harness resources to .github/harness/ (#992)" This reverts commit aef3890e460a9a06db7f8465a157588bc4b0f7b3. * refactor: move harness resources to .github/harness/ and use boto3 invoke_harness - Move harness_review.py, prompts/ to .github/harness/ - Add Dockerfile for the harness container (dual-token setup) - Add README documenting the harness directory - Update pr-ai-review workflow to reference new path - Replace manual SigV4 signing + urllib3 with native boto3 invoke_harness - Update .prettierignore for new prompts location * feat: update @aws/agent-inspector to 0.3.0 * fix(harness): add error handling for invoke_harness API call (#1056) Wrap the invoke_harness_streaming call in a try/except so boto3 errors (bad credentials, network issues, invalid ARN) produce a clean error message instead of a raw traceback in GitHub Actions logs. * chore: bump version to 0.12.2 Co-authored-by: github-actions[bot] * ci: cut full e2e time in half via vitest sharding (#1016) * ci: shard e2e full suite across 6 runners - Add 6-way vitest sharding to the cdk-source matrix (2 → 12 parallel runners) - Isolate import test resources per run via RESOURCE_SUFFIX to prevent concurrent conflicts * fix: import RESOURCE_SUFFIX in cleanup_resources.py * refactor: consolidate cli-config into global-config (#802) * feat: make parsing resilient to individual failures (#1062) * fix: forward custom headers in bearer token invoke paths (#1065) Custom headers passed via -H/--header were silently dropped when using CUSTOM_JWT auth because invokeWithBearerToken and invokeWithBearerTokenStreaming did not merge options.headers into the request headers. Extract buildBearerInvokeHeaders() (paralleling the existing buildMcpBearerHeaders) and use it in both invoke paths. Closes #1052 * feat: wire telemetry into all add.* commands (#1050) * feat: wire telemetry withCommandRun into all add.* commands * refactor: extract cliCommandRun helper, apply to all add.* primitives * test: add audit file assertions for all add.* telemetry * test: add telemetry audit assertions to existing add integ tests * refactor: extract shared audit test utils into src/test-utils/audit.ts * fix: address review feedback — guard telemetry init, replaceAll, unknown fallback, TUI try/catch * fix: AgentPrimitive TUI try/catch, standardize uses safeParse * refactor: extract standalone assertTelemetry helper * refactor: rename audit.ts to telemetry-helper.ts, clarify method names * refactor: move assertTelemetry into TelemetryHelper as assertMetricEmitted * feat: add telemetry to TUI add paths via withAddTelemetry * fix: review feedback — withAddTelemetry safety, standardize handles undefined, MCP agent attrs, policy TUI attrs * fix: remove unnecessary type assertion * fix: address review — document standardize cast, add policy-engine + episodic telemetry tests * refactor: centralize gateway target type mapping in common-shapes * fix: preserve original function error with telemetry wrapper * refactor: extract telemetryAttrs into a single line * feat: wire up telemetry for addAgent * fix: resolve e2e import test concurrency races (#1067) * fix: resolve e2e import test concurrency races Fix two independent concurrency issues causing flaky e2e import tests: 1. TOCTOU race in evaluator import (import-evaluator.ts): The beforeConfigWrite hook lists all online eval configs then fetches details for each with Promise.all. If a config is deleted between the list and get calls, the API throws 'Online evaluation configuration not found' and the entire import fails. Fixed by using Promise.allSettled and filtering out disappeared configs. 2. Resource name collisions across parallel CI shards (setup_*.py): Python setup scripts generated resource names using int(time.time()) (second-level precision). Parallel CI shards starting in the same second would collide with ConflictException. The test already passes a unique RESOURCE_SUFFIX env var but scripts ignored it for naming. Added NAME_SUFFIX to common.py that prefers RESOURCE_SUFFIX when set, and updated all setup scripts to use it. * chore: remove unused time imports from setup scripts * feat: evo preview features — config bundles, batch evaluation, recommendations, AB testing (#1068) * feat: add sync workflow * fix: formatting * fix: only sync to main branch * fix: codeql permissions * feat: add configuration bundle support Add ConfigBundle as a new resource type with full lifecycle: - Schema: ConfigBundleSchema with name validation, component configurations - Primitive: ConfigBundlePrimitive for add/remove operations - API client: SigV4-signed HTTP requests for config bundle CRUD operations - Deploy: post-deploy hook to sync config bundles with control plane - Status: config-bundle resource type in status command - TUI: add wizard (name, description, components, branch, commit message), remove flow, ResourceGraph integration - State: carry forward configBundles across redeploys in buildDeployedState * fix: use correct SigV4 service name for config bundle API The signing service must be 'bedrock-agentcore' for all stages, not 'bedrock-agentcore-control' for prod. The endpoint hostname differs from the signing service name. * fix: config bundle deploy and TUI defaults - Add config bundle post-deploy setup to TUI deploy flow (useDeployFlow) - Add clientToken to config bundle update API call - Add parentVersionIds on update (required by API) - Default branchName to "main" and commitMessage when not specified - Add placeholders for branch/message in TUI wizard - Fallback to find-by-name or create when update fails (stale IDs) - Remove debug logging from actions.ts * fix: use nullish coalescing for branchName default * feat: add edit config-bundle command with deploy diff check - Add `agentcore edit config-bundle` CLI command with --bundle, --components, --components-file, --description, --branch, --message, --json flags - Add interactive TUI wizard for editing config bundles (select bundle, input method, components, commit message, branch name, confirm) - Add diff check to post-deploy: skip API update when components and description are unchanged, avoiding unnecessary version creation - Use getConfigurationBundleVersion instead of getConfigurationBundle to avoid branch-not-found errors on bundles created with different branches - Align default branch name to 'mainline' (API default) instead of 'main' - For updates, inherit branch from current API state when not specified * test: add unit tests for edit config-bundle and deploy diff check - post-deploy-config-bundles: 13 tests covering create, update, skip (diff check), delete, branch inheritance, fallback paths, errors - ConfigBundlePrimitive.edit: 7 tests covering component updates, optional field handling, missing bundle errors, field preservation - useEditConfigBundleWizard: 16 tests covering step navigation, setters, goBack, reset, currentIndex tracking, step labels * fix: address review comments * fix: remove duplicate config-bundle subcommand from edit command * feat: config bundle version history CLI + TUI (#46) * chore: remove edit config-bundle command Users should edit agentcore.json directly to update config bundles. Removes the edit CLI command, TUI screens, wizard hooks, and tests. * feat: add config-bundle CLI commands for version history Adds `agentcore config-bundle` with three subcommands: - `versions` — list version history grouped by branch - `get-version` — view specific version details and components - `diff` — client-side deep diff between two versions Also adds filter support (branchName, latestPerBranch, createdBy) to the listConfigurationBundleVersions API client. * feat: add config bundle hub TUI screens Add TUI screens for browsing config bundles, viewing version history with branch grouping, version detail drill-down, and diff comparison between versions. * fix: resolve config bundle versionId when falling back to list API (#49) The Recommendation API requires versionId to be non-null when using configurationBundle input. When resolveBundleByName fell back to the list API (bundle not in deployed state), it returned no versionId, causing a 400 validation error. Now calls getConfigurationBundle after list to fetch the latest versionId. Also adds versionId to the ResolvedBundle interface and returns it from the deployed-state fast path. * chore: remove get-version subcommand from config-bundle CLI The versions --json and diff commands cover all practical use cases. Keeps the command surface lean: versions + diff only. * feat: add Recommendations API, TUI wizard, and CLI commands (#45) * feat: add Recommendation API wrappers, CLI commands, and operations layer Implement the Recommendations/Optimization feature for AgentCore CLI: - SigV4-signed HTTP client for Start/Get/List/Delete Recommendation (DP) - Operations layer with orchestration, polling, and local storage - CLI commands: evals recommend, evals recommendation history/delete, run promote - 27 unit tests covering API, storage, and orchestration logic - Live-validated field names and ARN formats against prod API * feat: add recommendation TUI wizard with session discovery and multi-evaluator support - Add full recommendation wizard TUI (type, agent, evaluators, input, trace source, sessions, confirm) - Add session discovery flow: discover sessions from CloudWatch, multi-select specific sessions - Support both CloudWatch logs and session ID trace sources - Pass selected sessionIds to recommendation API cloudwatchLogs config - Add request ID capture and error detail extraction for debugging FAILED recommendations - Fix recommendation API test mocks (add headers for requestId capture) - Add scrollable list support (maxVisibleItems) to MultiSelectList, SelectList, WizardSelect - Wire recommendation screen into App.tsx and EvalHubScreen navigation * feat: add session span fetching, recommendation tests, and TUI integration - Add fetch-session-spans module for retrieving OTEL spans from aws/spans and log records from runtime log groups with session ID filtering - Add comprehensive tests for fetch-session-spans (9 tests) and extend run-recommendation tests (12 new tests covering file input, spans-file trace source, tool-desc auto-fetch, error handling, ARN passthrough) - Wire recommendation hub, history screen, and list/delete CLI commands - Update TUI routing for recommendation flows from eval and run hubs - Add recommendation constants (poll intervals, terminal statuses) * chore: remove list commands and promote stub, fix agents→runtimes rename Remove `agentcore list recommendations` and `agentcore list recommendation --id` commands (top-level `list` command deleted entirely). Remove `run promote` stub. Fix typecheck errors from agents→runtimes schema rename in recommendation files. * feat: batch evaluation — stateless eval API, TUI wizard, local storage (#26) * feat: add EvaluationJob resource — schema, primitive, deploy hook, TUI, and tests Phase 1 of EvalJobRunner: CRUD + deploy integration for the EvaluationJob control plane resource. - Schema: EvaluationJobSchema in agentcore.json, deployed state tracking - Primitive: EvaluationJobPrimitive with add/remove lifecycle - AWS client: SigV4-signed HTTP wrappers for EvalJob CP operations - Deploy: post-deploy hook creates/updates/deletes eval jobs imperatively - CFN outputs: parse eval job execution role ARN from stack outputs - TUI: add evaluation-job wizard flow + remove flow integration - Tests: 53 tests across schema, primitive, AWS client, deploy hook, and TUI * feat: add `run evaluation-job` command with DP API wrappers and orchestration - Data plane API wrappers (RunEvaluationJob, GetEvaluationJobRun, ListEvaluationJobRuns) with SigV4 signing against bedrock-agentcore service - Orchestration: resolve job from deployed state, generate runId, start run, poll for completion, fetch results from CW Logs output group - CLI command: `agentcore run evaluation-job --job --session-id ` with --json output and progress callbacks - Tests: 17 new tests covering DP wrappers, runId generation, orchestration (error handling, polling, CW Logs result parsing) * feat: complete US1/US2 quick wins — run name, cancel, update, stage-aware endpoints - Add --run flag to `run evaluation-job` for custom run name prefixes - Add `run cancel-evaluation-job` command with StopEvaluationJobRun DP API - Add `update evaluation-job` primitive method and CLI subcommands - Add `agentcore update experiment` parent command (backward-compatible) - Make CP/DP endpoints stage-aware via AGENTCORE_STAGE env var (beta/gamma/prod) - Fix beta SigV4 service name (bedrock-agentcore vs bedrock-agentcore-control) - Update AddEvaluationJobFlow success screen with next-steps guidance * feat: add TUI run wizard, progress steps, and local result storage for eval jobs - Add RunEvalJobFlow TUI: select job → enter sessions → name run → confirm → execute - Add StepProgress display during eval job polling (starting → polling → fetching → saving) - Add elapsed time counter during run execution - Add eval-job-storage module: save/load/list run results per job in .cli/eval-job-results/ - Auto-save results on both CLI and TUI paths - Add "Evaluation Job" option to TUI Run screen - Add 9 unit tests for eval-job-storage * feat: add CloudWatch session discovery to eval job TUI wizard - Add source type picker: "Discover from CloudWatch" vs "Enter manually" - Add lookback days input (1-90 days) for CloudWatch discovery - Discover sessions via CW Insights query using agent's runtimeId - Multi-select from discovered sessions with span count + timestamps - Auto-fallback to manual entry when agent not deployed (no runtimeId) - Improve error display: show failed step in StepProgress before transitioning * feat: migrate evaluation from resource CRUD to stateless batch evaluation Replace the old EvaluationJob resource model (create/update/delete via agentcore.json + deploy hooks) with a flat BatchEvaluation API model: - Add `run batch-evaluation` and `run stop-batch-evaluation` CLI commands - Add batch evaluation TUI wizard under the Run menu - Add SigV4 API client for batch eval endpoints (start/get/list/stop) - Add CloudWatch results fetching from outputDataConfig - Remove all old evaluation-job infrastructure: primitive, deploy hook, schema, TUI add/remove screens, CP CRUD operations - Remove evaluationJobs from agentcore.json schema Tested end-to-end on gamma (account 998846730471) with Builtin.Faithfulness evaluator against 3 agent sessions — all returning correct scores. * chore: remove executionRoleArn now that FAS creds are live on gamma The batch evaluation API no longer requires an execution role ARN. Remove the --execution-role CLI option and all executionRoleArn plumbing from the API client and orchestration layer. * Revert "chore: remove executionRoleArn now that FAS creds are live on gamma" This reverts commit f1706ff7ea4b7695d1466e609cde29e38cb00afb. * refactor: move stop-batch-evaluation to top-level stop command Move `agentcore run stop-batch-evaluation` to `agentcore stop batch-evaluation` as a higher-level verb, consistent with pause/resume pattern. * fix: evo cleanup — sync public 0.7.1 + 6 bug fixes (#52) * ci: use draft releases for PR tarballs to avoid notifying watchers (#745) * feat: add code-based evaluator support (#739) * feat: add code-based evaluator support Add managed and external code-based evaluator support across schema, CLI flags, TUI wizard, and template scaffolding. Block code-based evaluators from online eval configs at schema, CLI, and TUI layers. * temp: use pyproject.toml with vendored SDK wheel Vendor the SDK wheel and add binary-aware template rendering until the SDK is published to PyPI. To be removed once the SDK is publicly available. * fix: update asset snapshot and regenerate package-lock.json - Update asset file listing snapshot for new evaluator templates - Regenerate package-lock.json to fix stale aws-cdk bundled dep (@aws-cdk/cloud-assembly-schema 52.2.0 -> 53.11.0) * fix: show correct evaluator type in status display Status command was hardcoding "LLM-as-a-Judge" for all evaluators. Now derives the label from item.config.codeBased to distinguish code-based evaluators. * feat: add additionalPolicies field to managed code-based evaluator config Add additionalPolicies to ManagedCodeBasedConfigSchema supporting both inline .json policy files and managed policy ARNs. Auto-populate with execution-role-policy.json when scaffolding managed evaluators. * revert: remove vendored wheel support and requirements.txt The SDK is now on PyPI (bedrock-agentcore>=1.6.0). Remove: - Binary-aware template rendering (.whl copy logic) - Vendored wheels from evaluator assets - requirements.txt references from scaffold messages pyproject.toml now pulls directly from PyPI. * fix: remove vendored wheel and pin bedrock-agentcore>=1.6.0 Remove the last vendored .whl from src/assets and update pyproject.toml to require bedrock-agentcore>=1.6.0 from PyPI. Update asset snapshot accordingly. * feat(import): add runtime and memory import subcommands with TUI wizard (#763) * feat(import): add runtime and memory import subcommands Add `agentcore import runtime` and `agentcore import memory` subcommands to import existing AWS resources into a CLI project. Includes 2-phase CFN import, source code copying, and shared utilities. Also adds TODO.md tracking entrypoint detection improvement and CFN Phase 2 handler investigation, and IMPORT_TESTING_SUMMARY.md with full E2E test results. Constraint: AWS API returns modified entryPoint array (with otel wrapper), not original Constraint: Commander.js parent options shadow same-named child options Rejected: --source flag on runtime subcommand | conflicts with parent import --source Confidence: high Scope-risk: moderate Not-tested: CFN Phase 2 import for runtimes (service-side HandlerInternalFailure) * fix(import): fail on undetectable entrypoint instead of silent fallback extractEntrypoint() now returns undefined when it cannot find a file with a known extension (.py/.ts/.js) in the API's entryPoint array, instead of silently falling back to main.py. Adds --entrypoint flag so users can specify the entrypoint manually when auto-detection fails. Constraint: AWS API returns modified entryPoint array with otel wrappers, not original Rejected: Silent fallback to main.py | wrong entrypoint causes silent deploy failures Confidence: high Scope-risk: narrow * chore: remove TODO.md and testing summary from branch * test(import): add unit tests for entrypoint detection and runtime import handler 11 tests for extractEntrypoint covering otel wrappers, missing/empty arrays, multiple extensions, and extensionless entries. 8 tests for handleImportRuntime covering entrypoint failure, --entrypoint override, missing --code, nonexistent source path, and duplicate runtime names. * fix(import): address PR review feedback - Validate entrypoint file exists inside --code directory - Improve --code help text to clarify it points to the entrypoint folder - Validate AWS credentials match target account via STS GetCallerIdentity - Fix project name prefix stripping to only strip known prefix, not any underscore - Rename sanitize() to replaceUnderscoresWithDashes() for clarity - Use existing Dockerfile template from assets instead of hardcoded duplicate * refactor: change --id to --arn on import runtime and memory subcommands Users now provide the full resource ARN instead of just the ID. The runtime/memory ID is extracted from the ARN's last path segment. * fix(import): extract reflectionNamespaces for EPISODIC memory strategies toMemorySpec was not mapping reflectionNamespaces from the API response, causing EPISODIC strategy imports to fail Zod schema validation which requires reflectionNamespaces for EPISODIC type strategies. * fix(import): validate ARN format, region, and account before extracting resource ID Previously, --arn was parsed with a blind split('/').pop() with no validation. Now parseAndValidateArn checks the ARN matches the expected format, resource type, and that region/account match the deployment target. * fix(import): throw on missing required fields in getMemoryDetail instead of silent defaults Previously, missing id/arn/name/eventExpiryDuration/strategy.type were silently replaced with empty strings or default values, hiding API response issues that would cause broken imports downstream. * fix(import): detect already-imported resources early and improve CFN error messages Check deployed-state.json before making any config changes to catch resources already imported in the current project. Also detect the "already exists in stack" CFN error and provide a friendlier message explaining the resource must be removed from the other stack first. * feat(import): capture tags during memory import Fetch tags via ListTagsForResource API and include them in the imported memory config. Tags already flow through the CLI schema and CDK construct, they just weren't being read from the API during import. * feat(import): capture encryptionKeyArn during memory import Add encryptionKeyArn to CLI schema, MemoryDetail, and toMemorySpec so imported memories preserve their KMS encryption key configuration. Also update CDK L3 construct to pass encryptionKeyArn through to CfnMemory. * feat(import): capture executionRoleArn during memory import Map the API field memoryExecutionRoleArn to executionRoleArn in CLI schema to match the runtime convention. Also update CDK L3 construct to use an imported role via Role.fromRoleArn when executionRoleArn is provided instead of always creating a new one. * refactor(import): deduplicate actions.ts by reusing import-utils utilities actions.ts reimplemented 5 utilities that already exist in import-utils.ts. Replace local definitions with imports and use updateDeployedState() instead of inline state manipulation. Removed: sanitize(), toStackName(), fixPyprojectForSetuptools(), COPY_EXCLUDE_DIRS, copyDirRecursive() — all duplicates of import-utils.ts. * fix(import): paginate listings, auto-select single result, and preserve runtime config Three import bugs fixed: 1. listAgentRuntimes/listMemories only fetched one page (max 100). Added listAllAgentRuntimes/listAllMemories that paginate via nextToken. 2. Single-result listing incorrectly showed "Multiple found" error. Now auto-selects when exactly one runtime/memory exists. 3. toAgentEnvSpec dropped env vars, tags, lifecycle config, and request header allowlist. Extended AgentRuntimeDetail and getAgentRuntimeDetail to extract these fields (including ListTagsForResource call for tags), and mapped them in toAgentEnvSpec. Confidence: high Scope-risk: moderate Not-tested: pagination with >100 real resources (no integration test account available) * test(import): add tests for pagination, field extraction, auto-select, and env var mapping Tests cover: - listAllAgentRuntimes/listAllMemories pagination across multiple pages - getAgentRuntimeDetail extraction of environmentVariables, tags (via ListTagsForResource), lifecycleConfiguration, requestHeaderAllowlist - toAgentEnvSpec mapping of env vars Record to envVars array, plus direct mapping of tags, lifecycle config, and header allowlist - Single-result auto-select when listing returns exactly 1 runtime - Error cases: empty listings, multiple results, absent fields * feat(import): auto-create deployment target from ARN when none exist When no deployment targets are configured, import runtime/memory now parses the --arn to extract region and account, then creates a default target automatically instead of requiring `agentcore deploy` first. * fix(import): omit runtimeVersion for Container builds Container runtimes have no runtimeVersion from the API, but toAgentEnvSpec was hardcoding PYTHON_3_12 as a fallback. Now runtimeVersion is optional in the schema and only set for non-Container builds. * fix(import): filter API-internal namespace patterns from memory import Memory strategies like SUMMARIZATION and USER_PREFERENCE include auto-generated namespace patterns (e.g. /strategies/{memoryStrategyId}/...) that are API-internal and should not be written to local agentcore.json. Constraint: Only filters namespaces containing {memoryStrategyId} template var Rejected: Strip all namespaces for non-SEMANTIC strategies | would lose user-defined namespaces Confidence: high Scope-risk: narrow * fix(import): show project context error before --code flag validation Commander's requiredOption() for --code runs before the action handler, so users outside a project see "required option not specified" instead of "no agentcore project found". Change to option() so the handler's project context check (step 1) runs first. The --code validation at step 5 still catches missing values after project context is confirmed. Constraint: Commander validates requiredOption before action handlers execute Rejected: Moving project check into a Commander hook | adds complexity for one flag Confidence: high Scope-risk: narrow * fix(import): address bugbash issues for import commands - Invalid ARN now returns "Not a valid ARN" before target resolution - Failed imports roll back agentcore.json and clean up copied app/ dirs - Discovery listings show ARNs (not just IDs) so users can copy them - Remove --target flag from import runtime/memory subcommands - Add description field to AgentEnvSpec schema and wire through import Constraint: Commander validates requiredOption before action handlers Constraint: Rollback is best-effort to avoid masking the original error Rejected: Keep --target on subcommands | silently falls back to default, confusing UX Confidence: high Scope-risk: moderate * feat(import): add interactive TUI wizard for import command Adds a multi-screen TUI flow for importing runtimes, memories, and starter toolkit configs, replacing the silent fall-through that previously occurred when selecting "import" in the TUI. Constraint: onProgress must be injectable so TUI can display step progress Rejected: Single text-input screen for all flows | each import type has different required fields Confidence: high Scope-risk: narrow * fix(import): add early name validation and allow re-import with --name Bug 5: Validate --name against the AgentNameSchema regex before any file I/O operations. Previously, a malicious --name like '../../../etc/pwned' would copy files outside the project directory and set up a Python venv there before schema validation rejected it. Now invalid names are caught immediately with a clear error message. Applied to both import-runtime and import-memory. Bug 6: Allow re-importing the same cloud resource under a different local name when --name is provided. Previously, the deployed-state duplicate check blocked all re-imports by resource ID regardless of --name. Now it only blocks when --name is not provided, and suggests using --name in the error message. When --name is provided, it warns and proceeds. Applied to both import-runtime and import-memory. * revert(import): restore original duplicate-by-ARN blocking behavior Bug 6 is not a bug — blocking re-imports of the same cloud resource ARN is correct because allowing it would create duplicate CFN logical resources referencing the same physical resource, causing deploy failures. Reverts the --name re-import allowance while keeping the Bug 5 early name validation fix. * feat(import): mark import command as experimental * fix(import): wire deploy next-step navigation and show dotfiles in file picker Two TUI fixes for the import flow: 1. ImportFlow now accepts onNavigate prop so selecting "Deploy" from next steps navigates to the deploy screen instead of going back. 2. PathInput gains a showHidden prop; YamlPathScreen uses it so .bedrock_agentcore.yaml is visible in the file picker. * refactor(import): extract shared CDK import pipeline to eliminate duplication The three import handlers (import-runtime, import-memory, actions) all repeated the same CDK build/synth/bootstrap/publish/phase1/phase2/state-update pipeline (~120 lines each). Extract this into executeCdkImportPipeline() in a new import-pipeline.ts module. Also add resolveImportContext() and failResult() helpers to import-utils.ts for shared setup and error handling. Net effect: -335 lines, zero behavior change, all 260 tests pass. Constraint: Must not change any observable behavior — pure structural refactor Rejected: Full strategy-pattern abstraction | over-engineering for 2 concrete cases Confidence: high Scope-risk: moderate Not-tested: actions.ts YAML import path with real AWS (infra limitation) * fix(import): launch TUI wizard when running agentcore import with no args Previously `agentcore import` with no --source flag showed help text. Now it launches the interactive ImportFlow TUI, matching the pattern used by `agentcore add` and other commands. * fix(import): wire deploy and status navigation from CLI-inline TUI When running `agentcore import` from CLI (not full TUI), selecting "deploy" or "status" from the next-steps menu now renders the corresponding screen instead of silently exiting. * style(import): fix prettier formatting in TUI screens * fix(security): update lodash and lodash-es to resolve high-severity vulnerabilities npm audit fix resolves CVE for code injection via _.template and prototype pollution via _.unset/_.omit in lodash <=4.17.23. * refactor(aws): extract createControlClient to avoid per-call client instantiation Each function in agentcore-control.ts was creating a new BedrockAgentCoreControlClient on every call, wasting HTTP connections and credential resolution. Extracted a shared createControlClient() factory and reuse a single client across paginated listAll* calls. * fix(import): log warnings on silent catch failures instead of swallowing errors Tag fetch failures in agentcore-control.ts and rollback failures in import-runtime.ts and import-memory.ts were silently swallowed. Users had no indication when config could be left in a broken state. Added console.warn calls matching the existing pattern in bedrock-import.ts. --------- Co-authored-by: Aidan Daly * fix(ci): regenerate lockfile for npm 11 compatibility (#770) npm 11 (shipped with Node 24.x) requires all optional dependency entries in the lock file, even for non-matching platforms. The lock file was generated with npm 10, which only records the current platform's optional deps (@esbuild/linux-x64). This caused npm ci to fail on the Node 24.x CI matrix entry with "Missing: @esbuild/* from lock file" errors. Regenerated with npm 11 to include all 26 @esbuild/* platform entries. Constraint: Lock file must be compatible with npm 10 (Node 20/22) and npm 11 (Node 24) Rejected: Pin CI to npm 10 | would mask the issue and delay migration Confidence: high Scope-risk: narrow Co-authored-by: Claude Opus 4.6 * ci: block schema changes in PRs (#712) The JSON schema is served live from the repo, so any commit to main that modifies it is effectively a release. Only the release workflow should regenerate and commit schema changes. * chore: bump version to 0.6.0 (#771) Co-authored-by: github-actions[bot] * fix: make add command description consistent with remove (#773) * chore(deps): bump vite from 8.0.3 to 8.0.5 (#777) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.0.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(fetch): add --identity-name option for custom credential lookup (#715) (#774) The `fetch access` command hardcoded credential lookup to `-oauth` via `computeManagedOAuthCredentialName()`, causing failures when users create identities with custom names. This adds an `--identity-name` option that lets users specify which credential to use for OAuth token fetch, falling back to the default convention when omitted. When no matching credential is found, the error message now lists all available OAuth credentials and suggests using `--identity-name`. Constraint: Must remain backward compatible — omitting --identity-name preserves existing behavior Rejected: Modify computeManagedOAuthCredentialName globally | would break other consumers Confidence: high Scope-risk: narrow Not-tested: TUI interactive flow and invoke command auto-fetch paths (noted as follow-up) * feat(status): display runtime invocation URL for deployed agents (#775) Show the runtime invocation URL in agentcore status output for each deployed agent. The URL is computed from the runtime ARN and target region, and displayed in CLI text output, JSON output, and the TUI ResourceGraph component. URL format: https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{encodedArn}/invocations Closes #716 Constraint: URL is only available when both targetConfig and runtimeArn exist Rejected: Reuse existing buildInvokeUrl from agentcore.ts | includes ?qualifier=DEFAULT which is for API invocation, not display Confidence: high Scope-risk: narrow * feat(create): add --skip-install flag to skip dependency installation (#782) Adds a --skip-install flag to `agentcore create` that skips all dependency installation (npm install for CDK and uv sync for Python). This enables enterprise users behind corporate proxies or private registries to modify package.json/pyproject.toml before installing dependencies manually. The flag sets the existing AGENTCORE_SKIP_INSTALL env var (previously only used in tests) and also implies --skip-python-setup behavior. A post-create message instructs users to install manually. * feat(import): add evaluator and online eval config import subcommands (#780) * feat(import): add evaluator import subcommand with TUI wizard Add `agentcore import evaluator` to import existing AWS evaluators into CLI projects. Refactor import types and utilities for extensibility so future resource types require minimal new code. Changes: - Add import-evaluator.ts handler with toEvaluatorSpec mapping (LLM-as-a-Judge and code-based evaluators), duplicate detection, and CDK import pipeline - Enhance getEvaluator API wrapper to extract full evaluatorConfig (model, instructions, ratingScale) and tags from SDK tagged unions - Add listAllEvaluators pagination helper filtering out built-in evaluators - Widen ImportableResourceType union and shared utilities for evaluator support - Add evaluator to TUI import flow (select, ARN input, progress screens) - Add 17 unit tests covering spec conversion, template lookup, and error cases Tested end-to-end against real AWS evaluator (bugbash_eval_1775226567-zrDxm7Gpcw) with verified field mapping for all config fields, tags, and deployed state. * fix(import): use correct importType for evaluator in TUI flow The TUI import wizard hardcoded importType as 'memory' for all non-runtime resources, causing evaluator imports to fail with "ARN resource type evaluator does not match expected type memory". Use flow.resourceType instead so the correct handler is dispatched. * feat(import): add online eval config import subcommand Add `agentcore import online-eval` to import existing online evaluation configs from AWS into CLI-managed projects. Follows the same pattern as runtime, memory, and evaluator imports. The command extracts the agent reference from the config's service names (pattern: {agentName}.DEFAULT), maps evaluator IDs to local names or ARN fallbacks, and runs the full CDK import pipeline. Also removes incorrect project-prefix stripping from evaluator and runtime imports — imported resources come from outside the project and won't have the project prefix. Constraint: Agent must exist in project runtimes[] before import (schema enforces cross-reference) Constraint: Evaluators not in project fall back to ARN format to bypass schema validation Rejected: Loose agent validation | schema writeProjectSpec() enforces runtimes[] cross-reference Confidence: high Scope-risk: moderate * feat(import): add online eval config to TUI import wizard Add 'Online Eval Config' option to the interactive import flow so users can import online evaluation configs via the TUI, not just the CLI. Follows the same ARN-only pattern as evaluator and memory imports: select type → enter ARN → import progress → success/error. * docs: add TUI import wizard screenshots for online eval Screenshots captured from the TUI import flow showing: - Import type selection menu with Online Eval Config option - ARN input screen for online eval config - ARN input with a real config ARN filled in * Revert "docs: add TUI import wizard screenshots for online eval" This reverts commit cb4c6757e66ffefe05c974d44e34754cff216196. * refactor(import): extract generic import orchestrator with descriptor pattern Reduce ~1,400 lines of duplicated orchestration across four import handlers (runtime, memory, evaluator, online-eval) to ~600 lines by extracting shared logic into executeResourceImport(). Each resource type now provides a thin descriptor declaring its specific behavior. Constraint: Public handleImport* function signatures unchanged (TUI depends on them) Constraint: Factory functions needed for runtime/online-eval to share mutable state between hooks Rejected: Strategy class hierarchy | descriptor objects are simpler and more composable Confidence: high Scope-risk: moderate * refactor(aws): extract paginateAll and fetchTags helpers in agentcore-control Deduplicates identical pagination loops across 4 listAll* functions and identical tag-fetching try/catch blocks across 3 getDetail functions. Also adds optional client param to listEvaluators and listOnlineEvaluationConfigs for connection reuse during pagination. Addresses deferred review feedback from PR #763. Constraint: evaluator listAll still filters out Builtin.* entries Confidence: high Scope-risk: narrow * fix(import): resolve evaluator references via deployed state for imported evaluators resolveEvaluatorReferences used string-contains matching (evaluatorId.includes(localName)) which only works when the evaluator was deployed by the same project. Imported evaluators with renamed local names never matched, falling back to raw ARNs in the config. Now reads deployed-state.json to build an evaluatorId → localName reverse map and checks it first, before the string-contains heuristic. Constraint: Deployed state may not exist yet (first import) — .catch() handles gracefully Rejected: Passing deployed state through descriptor interface | only online-eval needs this Confidence: high Scope-risk: narrow * fix(import): auto-disable online eval configs to unlock evaluators during import Evaluators referenced by ENABLED online eval configs are locked by the service (lockedForModification=true), causing CFN import to fail when it tries to apply stack-level tags. Now the evaluator import detects the lock, temporarily disables referencing online eval configs, performs the import, then re-enables them. Constraint: Re-enable runs in finally block so configs are restored on both success and failure Constraint: Only disables configs that actually reference this specific evaluator Rejected: Refuse import with manual guidance | user can't pause configs not yet in project Confidence: high Scope-risk: moderate * Revert "fix(import): auto-disable online eval configs to unlock evaluators during import" This reverts commit 583939153e336a72c6e5cd425dd02a834d73b9d0. * fix(import): block evaluator import when referenced by online eval, use ARN-only references Evaluators locked by an online eval config cannot be CFN-imported because CloudFormation triggers a post-import TagResource call that the resource handler rejects. Instead of stripping tags from the import template, block the import with a clear error and suggestion to use import online-eval. Online eval config import now always references evaluators by ARN rather than resolving to local names, since the evaluators cannot be imported into the project alongside the config. Constraint: CFN IMPORT triggers TagResource which fails on locked evaluators Rejected: Strip Tags from import template | still fails on some resource types Confidence: high Scope-risk: narrow * fix(import): resolve OEC agent reference via deployed state when runtime has custom name extractAgentName() derives the AWS runtime name from the OEC service name pattern, but this fails to match when the runtime was imported with --name since the project spec stores the local name. Now falls back to listing runtimes to find the runtime ID, then looks up the local name in deployed-state.json. * fix(import): strip CDK project prefix from OEC service name when resolving agent CDK constructs set the OEC service name as "{projectName}_{agentName}.DEFAULT". extractAgentName() strips ".DEFAULT" but not the project prefix, so the lookup fails against local runtime names. Now strips the prefix as a fast path before falling back to the deployed-state API lookup. * fix(import): show friendly error for non-existent evaluator ID getEvaluator() now catches ResourceNotFoundException and ValidationException from the SDK and rethrows a clear message instead of exposing the raw regex validation error. * fix(import): validate ARN resource type for online-eval import import online-eval used a naive regex to extract the config ID from the ARN, skipping resource type, region, and account validation. Now uses parseAndValidateArn like all other import commands. Added an ARN resource type mapping to handle the online-eval vs online-evaluation-config mismatch between ImportableResourceType and the ARN format. * refactor(import): address PR review feedback - Add `red` to ANSI constants, replace inline escape codes - Type GetEvaluatorResult.level as EvaluationLevel at boundary - Combine ARN_RESOURCE_TYPE_MAP, collectionKeyMap, idFieldMap into single RESOURCE_TYPE_CONFIG to prevent drift - Export IMPORTABLE_RESOURCES as const array, derive type from it, replace || chains with .includes() - Fix samplingPercentage === 0 false positive (use == null) - Document closure state sequencing contract on descriptor hooks * test(import): remove unreachable empty-level evaluator test The test exercised a defensive fallback in toEvaluatorSpec for an empty level string, but now that GetEvaluatorResult.level is typed as EvaluationLevel, the boundary cast in getEvaluator prevents this case from ever reaching toEvaluatorSpec. * feat: add custom dockerfile support for Container agent builds (#783) * feat: add custom dockerfile support for Container agent builds Add an optional `dockerfile` field to Container agent configuration, allowing users to specify a custom Dockerfile name (e.g. Dockerfile.gpu) instead of the default "Dockerfile". Changes across all layers: - Schema: Add dockerfile field to AgentEnvSpecSchema with filename validation - CLI wizard: Add "Custom Dockerfile" option to Advanced settings multi-select, with dedicated Dockerfile input step in the breadcrumb wizard - Dev server: Thread dockerfile through container config to docker build - Deploy preflight: Validate custom dockerfile exists before deploy - Packaging: Pass dockerfile to container build commands - Security: getDockerfilePath rejects path traversal (/, \, ..) - Tests: 64 new/updated tests across schema, preflight, dev config, packaging, wizard, and constants Constraint: Dockerfile must be a filename only (no path separators) Rejected: Accept full paths | path traversal security risk Rejected: Auto-copy Dockerfile on create | users manage their own Dockerfiles Confidence: high Scope-risk: moderate Not-tested: Interactive TUI tested manually via TUI harness (not in CI) * chore: remove dead code and redundant tests from dockerfile PR - Remove unused ADVANCED_GROUP_LABELS constant (dead code) - Remove unnecessary export on DOCKERFILE_NAME_REGEX - Fix stale `steps` dependency in useGenerateWizard setAdvanced callback - Trim computeByoSteps.test.ts to dockerfile-only tests (remove 11 tests for pre-existing behavior unchanged by this PR) - Remove redundant "uses default Dockerfile" tests that duplicate existing coverage in preflight, config, and container packager test files - Consolidate shell metacharacter it.each from 5 cases to 1 representative Confidence: high Scope-risk: narrow * fix: skip template Dockerfile scaffolding when custom dockerfile is set When a custom dockerfile is configured (e.g. Dockerfile.gpu), the renderer was still copying the default template Dockerfile into the agent directory, leaving an unused file alongside the custom one. Thread the dockerfile config through AgentRenderConfig and use a new exclude option on copyAndRenderDir to skip the template Dockerfile when a custom one is specified. The .dockerignore is still scaffolded. Constraint: copyAndRenderDir is a shared utility used by all renderers Rejected: Delete template after render | user requested option A (don't create) Confidence: high Scope-risk: narrow * fix: use PathInput file picker for Dockerfile selection in TUI Replace the TextInput with PathInput for Dockerfile selection in both the BYO add-agent and Generate wizard flows. This gives users a real file browser with directory navigation and existence validation on submit, matching the UX pattern used by the policy file picker. BYO flow: PathInput scoped to the agent's code directory so users browse their existing files and pick a Dockerfile. Generate flow: PathInput scoped to cwd so users browse the filesystem to find a Dockerfile to copy into the new project. Added allowEmpty and emptyHelpText props to PathInput so users can press Enter to use the default Dockerfile. Constraint: PathInput is a shared component used by policy and import screens Rejected: Soft warning on TextInput | user preferred real file picker like policy Confidence: high Scope-risk: narrow * feat(invoke,dev): add exec mode for running shell commands in runtimes (#750) * feat(invoke,dev): add exec mode for running shell commands in runtimes Add ! exec mode to invoke TUI for running shell commands in deployed runtimes, and ! local exec / !! container exec to dev TUI. Includes non-interactive --exec flag for both invoke and dev commands. - invoke TUI: type ! to enter exec mode, runs commands in deployed runtime - invoke CLI: --exec flag runs commands via InvokeAgentRuntimeCommand API - dev TUI: type ! for local exec, !! for container exec (Container builds) - dev CLI: --exec flag execs into running dev container (Container only) - TextInput: add onChange and onBackspaceEmpty props for mode switching - Pink/magenta hint text appears when exec input is empty, disappears on typing - Backspace on empty input reverses mode (!! -> ! -> normal) - IAM policy and docs updated with new bedrock-agentcore:InvokeRuntimeCommand Constraint: dev --exec CLI is Container-only since CodeZip users have local terminal Rejected: Ctrl+E hotkey for container exec | !! double-bang is more discoverable and consistent Confidence: high Scope-risk: moderate * fix: address review findings — side effects, DRY, dead code, validation CRITICAL: Move onBackspaceEmpty callback outside setState updater (React purity violation) and add text.length === 0 guard so it only fires when input is truly empty. HIGH: Extract shared runSpawnCommand helper in useDevServer to DRY up execCommand/execInContainer near-duplication (~100 lines → ~60 lines). Deslop: Remove dead providerInfo/modelProvider code paths, simplify singleValueStream to plain yield, replace options.prompt! assertions with early guard, remove redundant comments. Medium: Fix timeout:0 falsy check, add --exec+--stream validation, handle undefined exitCode explicitly, add SDK cast comment + runtime guard. Constraint: onBackspaceEmpty must fire outside setState to avoid double-fire in React 18 concurrent mode Rejected: Checking state inside updater for callback | React purity violation Confidence: high Scope-risk: narrow * feat(tui): sticky exec mode with distinct visual styling - Exec prompt (!) now uses magenta instead of yellow for clear differentiation from chat prompt (>) - Exec output renders in default terminal foreground, distinct from green chat responses — works on both dark and light terminals - Exec mode is sticky: ! prompt persists after running a command, exit via Escape or Backspace-on-empty - Conversation rendering upgraded to per-line colored output in InvokeScreen (matching DevScreen pattern) Constraint: Terminal color palette limited to 8 base colors Rejected: cyan for exec output | too similar to blue chat input Rejected: yellow for exec output | invisible on light terminal backgrounds Confidence: high Scope-risk: narrow * fix(tui): resolve 3 exec mode UX bugs from review feedback Bug 1: Escape in exec mode no longer exits the app. The Screen component's useExitHandler is disabled when in input mode (exitEnabled={mode !== 'input'}), so only TextInput's onCancel handles Escape. Escape in exec drops to > prompt; Escape from > goes to chat mode; Escape from chat exits. Bug 2: Dim prompt during command execution now shows ! or !! instead of always showing >, matching the current exec state. Bug 3: execInputEmpty is reset to true after command execution, so typing ! in sticky exec mode correctly escalates to !! (container exec) on container agents. * fix(tui): eliminate command flash during exec mode transitions The !! command and prompt would briefly vanish (replaced by >) when executing a container command, because setMode('chat') fired in a separate render batch from setConversation/setIsStreaming. Fix: add onStart callback to runSpawnCommand that fires in the same synchronous block as the conversation/streaming state updates, so React batches them together. The mode transition and conversation update now render atomically — no flash. * chore: bump version to 0.7.0 (#784) Co-authored-by: github-actions[bot] * fix(ci): pin npm version to avoid self-upgrade corruption (#785) npm install -g npm@latest fails on GitHub Actions runners when npm tries to replace its own modules mid-installation, corrupting the promise-retry dependency. Pin to npm@11.5.1 which is the minimum version needed for OIDC trusted publishing. * chore: bump version to 0.7.1 (#786) Co-authored-by: github-actions[bot] * fix: evo cleanup — 6 fixes for config bundles, recommendations, and batch eval 1. Remove "List Recommendations" from TUI hub (unimplemented API call) 2. Config bundle TUI hub reads from project config (agentcore.json) instead of listing all bundles from the API 3. Default config bundle branch name changed from "mainline" to "main" 4. Tool description recommendation: remove evaluator step (API does not accept evaluators); System prompt recommendation: single-select evaluator (exactly 1 required) 5. Tool description TUI: remove duplicate inputSource/content steps (tools step already collects tool name:description pairs) 6. Batch eval: only send executionRoleArn when explicitly provided via --execution-role flag (FAS creds work without it) * fix: standardize CLI flags, error handling, and batch eval TUI - Rename --agent to --runtime in batch eval and recommendation commands - Rename --days to --lookback in run eval command - Make --evaluator optional for tool-description recommendations (matches API) - Standardize evaluator flag descriptions across all run subcommands - Make deleteConfigurationBundle throw instead of returning {success, error} - Remove requestId from recommendation API error messages - Silence non-fatal tag fetch warnings in agentcore-control - Add StepProgress component to batch eval TUI with elapsed timer * feat: surface batch eval evaluatorSummaries and session stats - Add EvaluatorSummary and EvaluationResults types to batch eval API client - Extract evaluationResults from GetBatchEvaluation API response - CLI and TUI prefer API-provided evaluatorSummaries (averageScore, totalEvaluated, totalFailed) over local computation when available - Show session stats (total, completed, failed) in both CLI and TUI - Persist evaluationResults in saved batch eval records - Falls back to local CloudWatch-based aggregation when API summaries are not yet available (gamma currently returns session counts only; evaluatorSummaries is in beta and will roll forward) * docs: improve CLI help text, descriptions, and examples - Fix `run` parent description (was eval-only, now covers all 3 subcommands) - Fix `--agent-arn` reference in copy.ts (renamed to --runtime-arn) - Fix logs example using --agent (now --runtime) - Improve `evals` and `recommendations` descriptions for clarity - Add ground truth context to -A, --expected-trajectory, --expected-response - Improve recommendation option descriptions (--inline, --tools, --bundle-name) - Clarify --lookback as "How far back to search for traces in CloudWatch" - Clarify --evaluator as "required for system-prompt, optional for tool-description" - Remove "temporary — will be removed" from --execution-role - Improve batch eval description to mention CloudWatch sessions - Add CLI_ONLY_EXAMPLES for run eval, run batch-evaluation, run recommendation, config-bundle, stop, and evals commands --------- Signed-off-by: dependabot[bot] Co-authored-by: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Co-authored-by: Trirmadura J Ariyawansa Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Co-authored-by: Aidan Daly Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> * fix(ci): exclude .github/workflows/ from public repo sync (#54) GITHUB_TOKEN lacks the 'workflows' permission, so pushing workflow file changes from the public repo causes the sync to fail. Use --no-commit --no-ff and restore .github/workflows/ from HEAD before committing, in both the clean merge and conflict paths. * fix: evaluator resolution + config bundle filter field name (#55) * fix: resolve custom evaluator names to deployed IDs in batch eval Batch eval was sending project-level evaluator names (e.g. "MyCustomEval") instead of deployed evaluator IDs (e.g. "cbtest_MyCustomEval-xv2w2e42GL"). Builtin.* evaluators worked because the service resolves them directly, but custom evaluators need the deployed ID. Adds evaluator name resolution from deployed state, matching how run eval already resolves custom evaluator names in run-eval.ts. * fix: correct config bundle version filter field name to match API The ListConfigurationBundleVersions API expects `createdByName` (string) in the version filter, but the CLI was sending `createdBy` (string[]). This caused the filter to be silently ignored by the API. * fix: tool description comma parsing in recommendations (#56) CLI --tools option changed from comma-separated string to variadic (--tools "search:desc" --tools "calc:desc") so commas in descriptions don't break parsing. TUI uses a smarter regex split that only splits on commas followed by a valid tool name pattern and colon. * feat: add A/B test CLI support - ABTestPrimitive with add/remove following config bundle pattern - SigV4-signed HTTP client for AgentCore Evaluation Data Plane API - Post-deploy reconciliation creates/deletes AB tests + IAM roles - TUI: add wizard, remove flow, compact dashboard detail screen - CLI commands: add, remove, pause, resume, stop, view details - Auto-creates project-scoped IAM role with least-privilege permissions - Post-deploy warnings surfaced in TUI deploy screen - Comprehensive unit, hook, and integration tests * fix: route config-bundle to interactive TUI instead of CLI-only help screen config-bundle was listed in CLI_ONLY_EXAMPLES which intercepted the command before it could reach the ConfigBundleFlow TUI route, showing a static usage screen instead of the interactive bundle hub. * feat: add A/B test CLI support (#50) - ABTestPrimitive with add/remove following config bundle pattern - SigV4-signed HTTP client for AgentCore Evaluation Data Plane API - Post-deploy reconciliation creates/deletes AB tests + IAM roles - TUI: add wizard, remove flow, compact dashboard detail screen - CLI commands: add, remove, pause, resume, stop, view details - Auto-creates project-scoped IAM role with least-privilege permissions - Post-deploy warnings surfaced in TUI deploy screen - Comprehensive unit, hook, and integration tests * fix: TUI bug fixes + config bundle recommendation support (#57) * fix: batch eval name allows blank + evaluator list scrollable - Add allowEmpty to batch eval name TextInput so users can leave it blank for auto-generated names (placeholder said "leave blank" but validation blocked empty input) - Add maxVisibleItems={10} to evaluator WizardSelect/WizardMultiSelect in both recommendation and batch eval flows to prevent list overflow when many evaluators exist (enables arrow-key scrolling) * feat: add config bundle selection to recommendation TUI wizard Add 'Config bundle' as a third input source option in the recommendation TUI wizard (alongside 'Enter inline' and 'Load from file'). When selected, users can pick from deployed config bundles to use their system prompt as the recommendation input. - Add ConfigBundleItem type and 'bundle' step to wizard flow - Load deployed config bundles during initialization - Pass bundleName/bundleVersion to runRecommendationCommand - Only show config-bundle option when bundles are deployed * fix: config bundle recommendation uses local system prompt instead of broken API path The systemPromptJsonPath field in the config bundle API is broken server-side (all JSON path formats resolve to empty). Work around this by reading the system prompt from the local agentcore.json project config and passing it as inline text to the recommendation API. Also adds config bundle as an always-visible input source option in the recommendation TUI wizard, with proper empty-state handling when no bundles are deployed. * fix: single evaluator + config bundle field selection for recommendations (#61) * fix: enforce single evaluator for recommendations + bundle field selection - Recommendation API accepts exactly one evaluator for system-prompt (min:1, max:1) and none for tool-description. CLI flag changed from variadic --evaluator to singular --evaluator . Operations layer validates count before hitting API. - Config bundle recommendation now lets users pick which configuration field contains the system prompt, instead of guessing key names. New "Prompt Field" wizard step shows all string fields from the selected bundle's configuration. - Validates system prompt content is non-empty before API call, preventing the text:"" → 400 error reported by testers. * feat: add config bundle support for tool description recommendations - TUI wizard now shows config-bundle as input source for tool descriptions - bundleField (singular) → bundleFields (array) to support multi-select - System prompt: single-select picks one field as prompt content - Tool description: multi-select picks fields as toolName:description pairs - Fix confirm screen to display bundleFields correctly - Skip "Tools" confirm field when using config-bundle input * fix: use single-select for bundle field when only one field available - Tool desc with 1 field: WizardSelect (Enter to pick) - Tool desc with 2+ fields: WizardMultiSelect (Space to toggle) - Footer shows "Space to select" hint for multi-select mode * feat: add lookback days and session selection to batch evaluation - AWS client: add sessionInput (sessionIds + sessionFilterConfig) to CloudWatchSource - Operations: accept sessionIds and lookbackDays, compute time range, pass to API - CLI: add -d/--lookback-days and -s/--session-ids options for batch-evaluation - TUI: add lookback days input and session discovery/multi-select to batch eval wizard - Fix nit: remove "API accepts" mention from evaluator help text * fix: send sessionIds or sessionFilterConfig, not both API requires either sessionIds OR sessionFilterConfig in cloudWatchSource.sessionInput. When sessionIds are provided (from session picker), skip the time filter. * feat: batch eval ground truth + config bundle & status fixes (#62) * feat: ground truth support for batch evaluation (CLI + TUI) - Add sessionMetadata types (assertions, expected trajectory, turns) - Add --ground-truth/-g CLI flag to load ground truth from JSON file - Add TUI ground truth step with skip/file/inline options - Use PathInput file picker for ground truth file selection - Fix evaluator ARN resolution (extract short name from full ARN) - Merge session IDs from ground truth metadata with wizard selection - Fix PathInput Enter key to auto-select highlighted dropdown item - Fix TUI wizard Esc to go back one step instead of exiting * fix: display config bundles and ab tests in CLI status command The status command was computing statuses for config-bundle and ab-test resources but never rendering them. Also fixes empty target name display. * fix: resolve stale config bundle IDs with API fallback When config bundles are recreated, deployed-state.json retains old bundle IDs that no longer exist. Both CLI and TUI now fall back to the list API when the deployed-state bundleId returns a 404, fixing the "no differences found" issue in config bundle diff. * fix: batch eval uses deployed region from aws-targets Instead of relying solely on detectRegion() (which defaults to us-east-1), batch evaluation now reads the region from aws-targets.json first. This ensures it runs against the region where the agent is actually deployed. Applies to both CLI and TUI flows. * feat: add placeholder and format hint for config bundle components input Shows expected JSON format (component ARN → configuration map) as a hint above the inline input and as placeholder text. Also adds placeholder for file path input. * fix: improve config-bundle --help to clarify bundle name usage Clarifies that --bundle takes the bundle name from agentcore.json (not the AWS bundle ID), and that --from/--to version IDs come from the versions subcommand output. * fix: pass branchName to getConfigurationBundle API calls The GetConfigurationBundle API now requires a branchName query parameter. Without it, the API returns 404 "Configuration bundle branch not found". This was causing config-bundle versions/diff commands to fail even after the stale ID fallback resolved the correct bundle ID. * fix: show log file path on failure and saved results path on success For batch eval and recommendation commands (both CLI and TUI): - On failure: show the log file path so users can debug - On success: show the saved results file path (recommendation CLI was missing this; batch eval TUI now shows it too) * feat: AB test HTTP gateway, trace delivery, and TUI improvements (#63) * feat: add stop commands and Esc-to-stop for batch eval & recommendation (#65) * fix: always show gateway step in AB test wizard instead of silently skipping (#67) When no existing gateways are available, the wizard silently skipped the gateway step, leaving customers unaware that a gateway would be auto-created. Now the step always shows with "Create new HTTP gateway" as the only option, making the auto-creation explicit and visible. * feat: bundle Python SDK wheel into CLI for offline install - Add src/assets/wheels/ directory for bundled Python wheels - Add --find-links to uv pip install args (async + sync) to prefer local wheels over PyPI when present - Add wheel copy step to bundle.mjs for redundant safety - Include bedrock_agentcore-1.6.0 wheel from feat/evo_main branch When the CLI is installed from a bundle tarball, agentcore deploy will automatically use the bundled SDK wheel instead of fetching from PyPI, enabling fully offline preview distributions. * Fix __dirname in ESM bundle, update versions for evo-private-beta - Add __dirname/__filename shims to esbuild banner so bundled ESM CLI can resolve paths correctly (fixes ReferenceError at runtime) - Fix BUNDLED_WHEELS_DIR path: ../assets/wheels from dist/cli/ instead of ../../assets/wheels which resolved to the wrong directory - Bump CLI version to 0.8.0-evo-private-beta to avoid collision with public 0.8.0 release - Replace Python SDK wheel with 1.6.0.dev20260410 built from feat/evo_main branch of bedrock-agentcore-sdk-python-private * chore: update CLI version to 0.8.0-evo-private-beta-20260410 * fix: invocation URL in AB test views and listHttpGatewayTargets parsing (#68) * fix: show full invocation URL in AB test views and fix listHttpGatewayTargets parsing - Remove gateway URL from agent status view (only show for AB tests) - Rename 'Gateway URL' to 'Invocation URL' across AB test views - Fix listHttpGatewayTargets to parse 'items' key from API response - Fetch target name in AB test detail screen for full invocation URL - Add unit tests for listHttpGatewayTargets * chore: remove overwritten test file * fix: resolve config bundle ARN and recommendation improvements (#71) * fix: resolve config bundle ARN from deployed state for recommendations The recommendation CLI command was passing the human-readable bundle name (e.g. "MyBundle") as the bundleArn field in the API request instead of the actual ARN. This caused a 400 validation error from the API. Now resolves the bundle ARN from deployed state when using --bundle-name, and passes ARN values through directly (for TUI which already stores ARNs). Also includes: - Config bundle support in deploy preflight and CDK templates - JSONPath dot notation for config bundle field resolution - Session ID auto-fetch for all recommendation types (not just tool-desc) - Conditional tool explanation display (hide when empty) - CLI progress output for non-TUI recommendation command - Request IDs included in API error messages for debugging - Lint fixes in preflight.ts (type safety, deduplicated imports) * feat: auto-apply recommendation results to config bundles When recommendations use a config bundle as input source, automatically apply the results back to agentcore.json. CLI auto-applies after display, TUI offers an "Apply to config bundle" action. Supports both system prompt and tool description recommendations via JSONPath resolution. * refactor: sync config bundle from server instead of local apply The server automatically creates a new config bundle version when a recommendation uses a config bundle as input. Instead of locally parsing JSONPath and writing recommended text, fetch the server-created version via GetConfigurationBundleVersion and sync local agentcore.json + deployed state to match. * fix: hide empty explanation in recommendation results Check for non-empty explanation before displaying in both CLI and TUI. The API sometimes returns an empty string for explanation fields. * fix: remove hardcoded branchName 'main' in config bundle resolution resolveBundleByName was passing branchName: 'main' to GetConfigurationBundle, causing 404 errors for bundles on other branches (mainline, experiment, etc.). Omit branchName so the API returns the latest version regardless of branch. * feat: simplify add config bundle TUI with component picker Replace raw JSON/ARN input with a guided flow: select component type (runtime/gateway), pick from deployed resources, enter just the config values. Supports adding multiple components via "Add another?" loop. * fix: remove incorrect CW unsupported disclaimer for tool-desc recommendations CloudWatch trace source is supported for tool description recommendations. Remove the misleading note that said otherwise. * feat: support CloudWatch logs trace source for tool-desc recommendations * feat: add create config bundle option in AB test variant picker * feat: add batch eval history screen and duration hint - New BatchEvalHistoryScreen in evals hub for viewing past batch eval results - Add "Run Batch Evaluation" and "Batch Eval History" to evals hub - Add "This may take a few minutes..." message in CLI and TUI during batch eval * fix: config bundle Esc on componentType goes back instead of addAnother Pressing Esc on the component type step was incorrectly navigating to the "add another?" step when components already existed. Now it always goes to the previous step (description) as expected. * fix: resolve {{agent:name}} placeholder in config bundle component keys The resolveComponentKey function handled {{runtime:name}} and {{gateway:name}} but not {{agent:name}}, which is the documented… * fix: remove dead preflight patch and use proper teardown for evo resources (#1072) - Preflight: remove config bundle type patching entirely — the CDK schema does not have a type field, so neither the on-disk write (original) nor the in-memory patch was needed - Teardown: delegate to deleteOrphanedABTests and deleteOrphanedHttpGateways instead of raw deleteABTest/deleteHttpGatewayWithTargets — reuses stop/poll/delete/role-cleanup logic. Correct ordering: AB tests first (they create rules on gateways), then gateways, then config bundles. Closes #1070 * fix: remove dead preflight patch, proper teardown, optional evo schema fields (#1073) - Preflight: remove config bundle type patching entirely — the CDK schema does not have a type field, so the patch was dead code - Teardown: delegate to deleteOrphanedABTests and deleteOrphanedHttpGateways instead of raw API calls — reuses stop/poll/delete/role-cleanup logic. Correct ordering: AB tests first (they create rules on gateways), then gateways, then config bundles. - Schema: change configBundles, abTests, httpGateways from .default([]) to .optional() so they don't appear in agentcore.json unless the user opts into those preview features. All access sites updated with ?? [] or ??= guards. Closes #1070 * fix: revert .optional() to .default([]) and strip empty evo arrays on write (#1074) The .optional() change from #1073 breaks Zod JSON Schema generation ("Undefined cannot be represented in JSON Schema"). Revert to .default([]) so schema generation and parsing work, but strip empty configBundles/abTests/httpGateways in writeProjectSpec before writing to disk so preview features don't pollute agentcore.json. * fix: remove unnecessary non-null assertions after .default([]) revert (#1075) * chore: bump version to 0.13.0 (#1076) Co-authored-by: github-actions[bot] * docs: remove CrewAI from supported frameworks (#1059) CrewAI is not implemented as a framework option in the CLI — it does not exist in SDKFrameworkSchema, has no template assets, and is not in the SDK_MODEL_PROVIDER_MATRIX. Remove it from README and docs/frameworks.md to match the actual CLI capabilities. Constraint: Only frameworks in src/schema/constants.ts SDKFrameworkSchema are valid Rejected: Mark CrewAI as "BYO only" | no special BYO treatment exists for it Confidence: high Scope-risk: narrow * chore(deps): override glob to ^13 to silence install deprecation warning (#1008) Co-authored-by: minorun365 * fix: address formatting failure in docs (#1080) * test: remove http-gateway-targets e2e test (#1090) HTTP gateways are created through the AB test flow (add ab-test --mode target-based), not via agentcore add gateway. The standalone test was using MCP gateway commands against placeholder URLs that caused AWS::BedrockAgentCore::GatewayTarget CREATE_FAILED errors. HTTP gateway coverage is provided by ab-test-target-based.test.ts. Co-authored-by: Claude Sonnet 4.6 (1M context) * fix: sync e2e IAM policy and fix run eval flag (#1092) * fix: align E2E batch eval and recommendation tests with current API (#1093) * fix: use correct resourceType for config bundle in E2E status test (#1094) The status command outputs resourceType as 'config-bundle' (hyphenated) but the test was checking for 'configBundle' (camelCase). * docs: clarify integration vs e2e test boundaries and add e2e README (#1111) * docs: clarify integration vs e2e test boundaries and add e2e README - Rewrite integ-tests/README.md to accurately describe what integration tests do (local file/stdout assertions, no AWS required) — the old README described e2e behavior (CloudFormation, credentials, costs) - Add e2e-tests/README.md documenting the AWS boundary, prerequisites, createE2ESuite() pattern, key patterns, and cleanup requirements - Update docs/TESTING.md to add an E2E Tests section with a link to the new README, fix the stale integration tests section that incorrectly listed AWS credentials as a prerequisite, and add link to integ README * chore: fix prettier formatting in docs and READMEs * feat: add archive command for batch evaluations and recommendations (#1112) * feat: add archive command for batch evaluations and recommendations. Introduces a new top-level archive command with two subcommands: archive batch-evaluation and archive recommendation. Each calls the corresponding service delete API and removes the matching local .cli/ history file. * fix: prefix HTTP gateway names with project name to prevent cross-project collisions (#1105) * test: collapse schema enumeration tests and remove duplicates (#1087) * test: collapse schema enumeration tests and remove duplicate help text tests Collapse it.each per-value enumeration patterns into consolidated tests across 9 schema test files. Each enum value was tested individually (e.g., 4 separate 'accepts SEMANTIC', 'accepts SUMMARIZATION' tests) -- now represented by 1-2 tests with representative values. Remove deploy/dev --help unit tests (covered by integ-tests/help.test.ts) and 5 it.todo placeholders from credential-ops.test.ts. Schema tests: ~506 -> ~415. All cross-field validation, superRefine, boundary, and discriminated union tests preserved. * fix: address review comments — restore help tests and add .options assertions - Restore deploy --help and dev --help flag-level assertions (integ smoke test only checks exit code and "Usage:", not specific flags) - Restore --invoke negative regression guard in dev.test.ts - Replace representative-sample enum tests with .options assertions for AgentCoreRegionSchema and GatewayTargetTypeSchema (catches accidental removal of enum values) * fix: set iamRoleFallback to true for lambda gateway targets (#1086) * fix: set iamRoleFallback to true for lambda gateway targets Keep TARGET_TYPE_AUTH_CONFIG in sync with @aws/agentcore-cdk — lambda targets need GATEWAY_IAM_ROLE just like lambdaFunctionArn targets. Related: https://github.com/aws/agentcore-cli/issues/1005 * chore: trigger CI re-run * style: fix prettier table alignment in docs/frameworks.md * fix: correct AB test execution role IAM policy and promote stability (#1120) * fix: add GetEvaluator permission to AB test execution role The AB test API validates evaluator ARNs server-side using the execution role. The auto-created role policy was missing bedrock-agentcore:GetEvaluator, causing a 400 "Access denied when validating evaluator" error on every deploy that included a target-based AB test with a custom evaluator. * fix: correct status assertion in target-based AB test e2e HTTP gateways are not surfaced as top-level resources in agentcore status — they are only used internally to build AB test invocation URLs. The test was asserting on resourceType 'http-gateway' which never appears; fix it to assert on the 'ab-test' resource instead. * fix: add invocationUrl to status resource type cast in e2e test The resource cast was missing invocationUrl, causing a TypeScript error when asserting the AB test's gateway invocation URL was present. * fix: improve promote error message and add local e2e run script - Include stdout in promote failure message so the JSON error is visible - Add scripts/run-e2e-local.sh to replicate the GitHub Actions e2e workflow locally * fix: retry promote stop on 409 UPDATING status When promote is called immediately after resume, the AB test may still be in UPDATING state and reject the STOPPED transition with a 409. Retry up to 6 times with a 10s delay to wait for the transition to complete. * fix: wait for AB test to leave UPDATING state before promoting Poll getABTest until executionStatus is no longer UPDATING before attempting to stop. The service rejects updates with 409 while a state transition is in progress (e.g. after resume). * fix: wait for RUNNING before stopping AB test in promote Poll getABTest until executionStatus === 'RUNNING' before issuing the STOPPED transition. The service 409s if the test is still UPDATING (e.g. transitioning from a prior resume). * fix: resolve evaluator ARNs transitively from AB test online eval configs Previously GetEvaluator was scoped to all project evaluators. Now it's scoped to only the evaluators referenced by the specific online eval configs this AB test uses, handling all three cases: - Custom evaluators: looked up from deployedResources.evaluators - Builtin.* evaluators: ARN constructed from the builtin ID - External ARN references: used as-is * fix: align AB test execution role policy with official docs Replace the hand-rolled per-resource policy with the canonical policy from https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/ab-testing-prereqs.html: - Trust policy: add SourceAccount + SourceArn conditions - Permissions: single AgentCoreResources statement scoped to arn:aws:bedrock-agentcore:*:${accountId}:* with ResourceAccount condition - Add missing actions: GetGatewayTarget, ListGatewayTargets, ListConfigurationBundleVersions - CloudWatch logs scoped to account via PrincipalAccount pattern - Remove per-evaluator/per-resource ARN tracking (no longer needed) * test: update IAM role unit tests to match new policy structure * fix: throw error if AB test never reaches RUNNING before promote * test: add unit tests for promote polling logic and extract to promote-utils Extract waitForRunningThenStop into promote-utils.ts so it can be unit tested without pulling in React/ink. Add 4 tests covering: immediate RUNNING, polling until RUNNING, timeout throws, and error message content. * fix: split DescribeLogGroups into wildcard resource statement * test: verify AB test reaches RUNNING status after deploy in both e2e suites * fix: remove console.log statements that leaked ARNs and fix bundle ARN/version format in config-bundle e2e test * test: remove second deploy and RUNNING check from config-bundle e2e (follow-up with real bundles) --------- Co-authored-by: Local E2E * chore: bump version to 0.13.1 (#1129) Co-authored-by: github-actions[bot] * chore(deps-dev): bump secretlint from 12.3.1 to 13.0.0 Bumps [secretlint](https://github.com/secretlint/secretlint) from 12.3.1 to 13.0.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v12.3.1...v13.0.0) --- updated-dependencies: - dependency-name: secretlint dependency-version: 13.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * fix: pin a2a-sdk below 1.0 to prevent breaking changes The a2a-python SDK recently released 1.0 with breaking protocol changes (0.3 spec -> 1.0 spec). Our A2A templates use 0.3-era APIs (a2a.server, a2a.types, a2a.utils) which are incompatible with the 1.0 release. Add an upper bound (< 1.0.0) to all three A2A template pyproject.toml files so pip resolves to 0.3.26 (latest compatible) instead of 1.0.x. * chore(deps): bump @opentelemetry/exporter-metrics-otlp-http (#1141) Bumps [@opentelemetry/exporter-metrics-otlp-http](https://github.com/open-telemetry/opentelemetry-js) from 0.214.0 to 0.217.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/experimental/v0.214.0...experimental/v0.217.0) --- updated-dependencies: - dependency-name: "@opentelemetry/exporter-metrics-otlp-http" dependency-version: 0.217.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump react from 19.2.5 to 19.2.6 (#1136) Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.5 to 19.2.6. - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react) --- updated-dependencies: - dependency-name: react dependency-version: 19.2.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(deploy): pass stack selection to diff and deploy for --target filtering (#980) (#1148) * feat(harness): add verdict prefix to reviewer comments (#1153) * feat(harness): add verdict prefix requirement to reviewer system prompt The PR reviewer agent now must begin every comment with APPROVED, APPROVED WITH MINOR COMMENTS, or REQUESTING CHANGES so outcomes are immediately visible and parseable. * feat(harness): use gh pr review for formal review verdicts Instead of plain comments with text prefixes, the reviewer agent now submits formal GitHub PR reviews via `gh pr review` with --approve, --request-changes, or --comment. This integrates with branch protection rules and makes the review status machine-readable. * refactor(harness): move review verdict to review.md as one-liner Keep system.md as pure workspace context. The review task prompt now ends with a single directive to submit a formal PR review rather than a plain comment. * Update PR review submission instructions Clarified instructions for submitting PR reviews. * Add labels to Slack issue notification payload (#1162) * fix: apply prettier formatting to review.md (#1167) * ci: add workflow to create issues on CI failure (#1174) * ci: add workflow to create issues on CI failure * style: fix prettier formatting * ci: use AUTOMATION_ACCOUNT_PAT_TOKEN for issue creation (#1176) * fix: use search API with listForRepo fallback for issue dedup (#1180) * chore(deps-dev): bump hono from 4.12.14 to 4.12.18 (#1152) Bumps [hono](https://github.com/honojs/hono) from 4.12.14 to 4.12.18. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.14...v4.12.18) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.18 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: resolve high-severity npm audit vulnerabilities (#1184) * fix: resolve high-severity npm audit vulnerabilities Run npm audit fix to address: - fast-xml-builder: attribute value quote bypass (high) - fast-uri: path traversal via percent-encoded dot segments (high, prod dep) - uuid: missing buffer bounds check (moderate) Remaining moderate vulnerabilities are in @aws-sdk transitive deps (fast-xml-parser < 5.7.0) which require upstream SDK updates. * fix: remove stale fast-xml-parser and @aws-sdk/xml-builder overrides Both override conditions are now met by upstream: - @aws-sdk/xml-builder@3.972.22 pins fast-xml-parser@5.7.2 - @aws-sdk/core@3.974.8 requires @aws-sdk/xml-builder ^3.972.22 Removing the overrides allows the natural resolution to pick up the patched versions, clearing the remaining moderate advisory (GHSA-gh4j-gqv2-49f6, fast-xml-parser < 5.7.0). security:audit now reports 0 vulnerabilities (--omit=dev). * test: remove unnecessary mocks, use real filesystem (#1156) * test(resolve-ui-dist-dir): replace fs mock with real temp directory * test(BaseRenderer): remove APP_DIR and fs mocks, use real temp directories * test(get-trace): remove fs mock, verify file output with real filesystem * test(update-notifier): remove fs and compareVersions mocks, use real filesystem - Replace fs/promises mock with real temp directory via GLOBAL_CONFIG_DIR - Remove compareVersions mock, let real pure function run - Refactor source to use existing GLOBAL_CONFIG_DIR (supports AGENTCORE_CONFIG_DIR env var) - Only mock fetchLatestVersion (network) and PACKAGE_VERSION (constant) * test: address review feedback on mock removal changes - Remove redundant CACHE_DIR alias, use tmpDir directly - Add force:true to afterAll rmSync for robustness - Wrap chmod test in try/finally to prevent leaked read-only state - Remove vacuous conditional assertion in resolve-ui-dist-dir - Switch get-trace to beforeEach/afterEach for test isolation consistency * review.md: specify I/O boundary examples (network, AWS SDK, HTTP) * fix: handle CloudFormation throttling in import gateway polling (#1185) * fix: handle CloudFormation throttling in import gateway polling Adds a shared poll() utility with throttle-aware retry and migrates phase2-import.ts to use it. Previously, Rate exceeded errors from CloudFormation during concurrent e2e tests would crash the import operation. Now throttle errors are retried on the next poll iteration. Fixes: import-gateway e2e test failures under parallel execution * fix: preserve lastError as cause in poll errors, add domain error wrapping Addresses PR review feedback: - PollExhaustedError and PollTimeoutError now include the last error as `cause` for debuggability (e.g., shows 'Rate exceeded' when throttling exhausts retries) - phase2-import.ts wraps poll errors with operation-specific messages ('Timed out waiting for change set creation') preserving original error as cause - Fixed misleading message when maxConsecutiveErrors triggers (now reports actual attempt count) - Added 3 tests verifying cause propagation * test: scope fake timers to beforeEach/afterEach to prevent leaks * docs: add bedrock:CountTokens to IAM policy examples (#1181) Strands SDK uses bedrock:CountTokens to estimate token usage before model calls, enabling proactive context window management. Users running Strands-based agents need this permission in their IAM policies. Co-authored-by: Local E2E * fix: resolve target-based AB test target name mismatch (#1188) * docs: split TESTING.md into focused per-type docs (#1192) * docs: split TESTING.md into per-type docs with manual testing requirement Break the monolithic TESTING.md into focused files under docs/testing/ (unit, integration, TUI, browser, e2e, manual) and add a requirement that every change must be manually tested before submitting. * fix: address review comments on testing docs - e2e-tests.md: use correct `npm run test:e2e` command, add AWS creds prerequisite - manual-testing.md: fix tarball glob to `aws-agentcore-*.tgz` * style: fix prettier formatting in testing docs * feat: wire telemetry into all remove.* commands (#1069) * fix: widen TUI panel to prevent text truncation (#1191) (#1193) * fix: widen TUI panel to prevent text truncation in advanced settings The panel was hard-capped at 60 characters, causing option descriptions to be cut off with ellipses on wide terminals. Raised the cap to 100 and switched MultiSelectList from truncate to wrap for graceful handling on narrow terminals. Closes #1191 * docs: add screenshot for PR * chore: remove screenshot from repo * fix: default Panel to fullWidth so it fills the terminal Addresses reviewer feedback — panels now expand to fill available terminal width by default instead of capping at a fixed column count. * fix: revert wrap change, keep truncate for multi-select list Since panels now default to fullWidth, the truncation issue is solved by the wider panel itself. Keeping truncate avoids visual ambiguity on narrow terminals where wrapped text could overlap adjacent rows. * test: replace obsolete contentWidth test with fullWidth assertion * fix: remove content width cap, use full terminal width everywhere * fix: replace manual width strings with Ink layout components Remove buildLogo and manual '─'.repeat(contentWidth) dividers. Use Ink's Box with borderStyle and width="100%" instead, letting the layout engine handle fitting within parent padding automatically. * docs: add telemetry instrumentation guide (#1197) --------- Signed-off-by: dependabot[bot] Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Co-authored-by: Trirmadura J Ariyawansa Co-authored-by: Avi Alpert <131792194+avi-alpert@users.noreply.github.com> Co-authored-by: Gitika <53349492+notgitika@users.noreply.github.com> Co-authored-by: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Imran Ismail Co-authored-by: Claude Opus 4.6 Co-authored-by: Jesse Turner Co-authored-by: Avi Alpert Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Álvaro <159990212+alvarog2491@users.noreply.github.com> Co-authored-by: Yasuhiro Horiuchi Co-authored-by: kashinoki38 <21358299+kashinoki38@users.noreply.github.com> Co-authored-by: padmak30 Co-authored-by: padmak30 Co-authored-by: Minoru Onda(みのるん) <74597894+minorun365@users.noreply.github.com> Co-authored-by: minorun365 Co-authored-by: Local E2E --- .github/harness/prompts/review.md | 11 + .github/workflows/ci-failure-issue.yml | 72 +++ .github/workflows/e2e-tests-full.yml | 2 +- .../workflows/slack-issue-notification.yml | 1 + AGENTS.md | 4 + docs/TESTING.md | 446 +----------------- docs/policies/iam-policy-boundary.json | 2 +- docs/policies/iam-policy-user.json | 2 +- docs/testing/browser-tests.md | 74 +++ docs/testing/e2e-tests.md | 29 ++ docs/testing/integration-tests.md | 23 + docs/testing/manual-testing.md | 34 ++ docs/testing/tui-tests.md | 220 +++++++++ docs/testing/unit-tests.md | 107 +++++ integ-tests/add-remove-ab-test.test.ts | 19 +- integ-tests/add-remove-config-bundle.test.ts | 46 +- integ-tests/add-remove-evaluator.test.ts | 12 +- integ-tests/add-remove-gateway.test.ts | 32 +- integ-tests/add-remove-policy.test.ts | 17 +- integ-tests/add-remove-resources.test.ts | 54 ++- package-lock.json | 290 ++++++------ package.json | 6 +- src/cli/__tests__/update-notifier.test.ts | 133 +++--- src/cli/commands/import/phase2-import.ts | 94 ++-- src/cli/commands/remove/command.tsx | 10 +- .../__tests__/post-deploy-ab-tests.test.ts | 61 +++ .../operations/deploy/post-deploy-ab-tests.ts | 9 +- .../__tests__/resolve-ui-dist-dir.test.ts | 44 +- .../traces/__tests__/get-trace.test.ts | 39 +- src/cli/primitives/ABTestPrimitive.ts | 5 +- src/cli/primitives/AgentPrimitive.tsx | 4 +- src/cli/primitives/BasePrimitive.ts | 8 +- src/cli/primitives/CredentialPrimitive.tsx | 4 +- src/cli/primitives/EvaluatorPrimitive.ts | 4 +- src/cli/primitives/GatewayPrimitive.ts | 6 +- src/cli/primitives/GatewayTargetPrimitive.ts | 8 +- src/cli/primitives/MemoryPrimitive.tsx | 4 +- .../primitives/OnlineEvalConfigPrimitive.ts | 4 +- src/cli/primitives/PolicyEnginePrimitive.ts | 8 +- src/cli/primitives/PolicyPrimitive.ts | 6 +- .../primitives/RuntimeEndpointPrimitive.ts | 8 +- src/cli/telemetry/README.md | 104 ++++ src/cli/telemetry/cli-command-run.ts | 96 ++-- src/cli/telemetry/schemas/command-run.ts | 20 + .../templates/__tests__/BaseRenderer.test.ts | 44 +- src/cli/tui/components/Panel.tsx | 2 +- .../tui/components/__tests__/Panel.test.tsx | 31 +- src/cli/tui/context/LayoutContext.tsx | 30 +- .../context/__tests__/LayoutContext.test.ts | 33 -- src/cli/tui/context/index.ts | 2 +- src/cli/tui/hooks/useCreateEvaluator.ts | 4 +- src/cli/tui/hooks/useCreateMcp.ts | 4 +- src/cli/tui/hooks/useCreateMemory.ts | 4 +- src/cli/tui/hooks/useCreateOnlineEval.ts | 4 +- src/cli/tui/hooks/useRemove.ts | 8 +- src/cli/tui/screens/agent/useAddAgent.ts | 4 +- .../tui/screens/home/CommandListScreen.tsx | 7 +- src/cli/tui/screens/home/HelpScreen.tsx | 26 +- src/cli/tui/screens/home/HomeScreen.tsx | 22 +- .../tui/screens/identity/useCreateIdentity.ts | 4 +- src/cli/tui/screens/policy/AddPolicyFlow.tsx | 6 +- src/cli/tui/screens/remove/useRemoveFlow.ts | 29 +- .../AddRuntimeEndpointFlow.tsx | 4 +- src/cli/update-notifier.ts | 16 +- src/lib/index.ts | 1 + src/lib/time-constants.ts | 4 + src/lib/utils/__tests__/polling.test.ts | 217 +++++++++ src/lib/utils/index.ts | 1 + src/lib/utils/polling.ts | 109 +++++ 69 files changed, 1754 insertions(+), 1044 deletions(-) create mode 100644 .github/workflows/ci-failure-issue.yml create mode 100644 docs/testing/browser-tests.md create mode 100644 docs/testing/e2e-tests.md create mode 100644 docs/testing/integration-tests.md create mode 100644 docs/testing/manual-testing.md create mode 100644 docs/testing/tui-tests.md create mode 100644 docs/testing/unit-tests.md create mode 100644 src/cli/telemetry/README.md delete mode 100644 src/cli/tui/context/__tests__/LayoutContext.test.ts create mode 100644 src/lib/time-constants.ts create mode 100644 src/lib/utils/__tests__/polling.test.ts create mode 100644 src/lib/utils/polling.ts diff --git a/.github/harness/prompts/review.md b/.github/harness/prompts/review.md index 67cb54fec..be71d2818 100644 --- a/.github/harness/prompts/review.md +++ b/.github/harness/prompts/review.md @@ -16,3 +16,14 @@ choose. Skip style nits and minor suggestions — only flag things that actually When finished, submit a formal PR review (approve or request changes) with individual and inline comments in it. Be specific with line numbers. + +If all serious issues have already been raised in existing comments, or if you found no new issues, post a single +comment on the PR saying it looks good to merge (or that all issues have already been flagged). + +## Patterns to look out for + +- **Excessive mocking** — Avoid excessive mocking; it couples tests to implementation details, provides weaker + guarantees, and often points to mismanaged dependencies. Prefer real dependencies (e.g. temp directories over fs + mocks) and only mock at true I/O boundaries (e.g., network calls, AWS SDK clients, HTTP requests). +- **Missing telemetry** — New features should include telemetry instrumentation. See `src/cli/telemetry/README.md` for + guidance on what and how to instrument. diff --git a/.github/workflows/ci-failure-issue.yml b/.github/workflows/ci-failure-issue.yml new file mode 100644 index 000000000..2114154ba --- /dev/null +++ b/.github/workflows/ci-failure-issue.yml @@ -0,0 +1,72 @@ +name: CI Failure Issue + +on: + workflow_run: + # These names must match the `name:` field in each workflow file exactly + workflows: + - 'Build and Test' + - 'Quality and Safety Checks' + - 'E2E Tests (Full Suite)' + - 'CodeQL' + types: [completed] + +jobs: + create-issue: + concurrency: ci-failure-issue-${{ github.event.workflow_run.name }} + runs-on: ubuntu-latest + timeout-minutes: 5 + if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.head_branch == 'main' }} + permissions: + issues: write + steps: + - uses: actions/github-script@v9 + with: + github-token: ${{ secrets.AUTOMATION_ACCOUNT_PAT_TOKEN }} + script: | + try { + const workflowName = context.payload.workflow_run.name; + + if (!/^[A-Za-z0-9 _()\-]+$/.test(workflowName)) { + core.setFailed(`Unexpected characters in workflow name: ${workflowName}`); + return; + } + + const sha = context.payload.workflow_run.head_sha; + const runUrl = context.payload.workflow_run.html_url; + const branch = context.payload.workflow_run.head_branch; + const title = `CI Failure: ${workflowName}`; + + const { data } = await github.rest.search.issuesAndPullRequests({ + q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open in:title "${title}"` + }); + + if (data.items.some(i => i.title === title)) { + core.info(`Issue already exists for "${title}"`); + return; + } + + // Fallback: check recent issues in case search index is stale + const { data: recent } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'created', + direction: 'desc', + per_page: 30 + }); + + if (recent.some(i => i.title === title)) { + core.info(`Issue already exists for "${title}" (found via fallback)`); + return; + } + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + labels: ['high-severity', 'ci'], + body: `## CI Failure\n\n- **Workflow:** ${workflowName}\n- **Branch:** ${branch}\n- **Commit:** ${sha}\n- **Run:** ${runUrl}\n\nPlease investigate this failure.` + }); + } catch (error) { + core.setFailed(`Failed to create CI failure issue: ${error.message || error}`); + } diff --git a/.github/workflows/e2e-tests-full.yml b/.github/workflows/e2e-tests-full.yml index 6d68f1c3a..b34410e8f 100644 --- a/.github/workflows/e2e-tests-full.yml +++ b/.github/workflows/e2e-tests-full.yml @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: main + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'main' }} - uses: actions/setup-node@v6 with: node-version: '20.x' diff --git a/.github/workflows/slack-issue-notification.yml b/.github/workflows/slack-issue-notification.yml index 1d3bbc4ee..630b978ae 100644 --- a/.github/workflows/slack-issue-notification.yml +++ b/.github/workflows/slack-issue-notification.yml @@ -20,4 +20,5 @@ jobs: issue_author: "${{ github.event.issue.user.login }}" issue_body: ${{ toJSON(github.event.issue.body) }} repository: "${{ github.repository }}" + labels: "${{ join(github.event.issue.labels.*.name, ', ') }}" created_at: "${{ github.event.issue.created_at }}" diff --git a/AGENTS.md b/AGENTS.md index 104b902bf..c5db221ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,6 +143,10 @@ See `docs/TESTING.md` for details. - Always look for existing types before creating a new type inline. - Re-usable constants must be defined in a constants file in the closest sensible subdirectory. +## Telemetry + +New features must include telemetry instrumentation. See `src/cli/telemetry/README.md` for how to add metrics. + ## Multi-Partition Support (GovCloud, China) The CLI supports multiple AWS partitions (commercial, GovCloud, China) through a central utility at diff --git a/docs/TESTING.md b/docs/TESTING.md index 601cf258f..68bd9e7a0 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -11,414 +11,20 @@ npm run test:browser # Run browser tests (requires AWS creds, uv, agentcore) npm run test:all # Run all tests (unit + integ) ``` -## Test Organization +## Test Types -### Unit Tests +| Type | Description | Docs | +| ----------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Unit | Co-located tests for individual modules, includes snapshot tests | [testing/unit-tests.md](testing/unit-tests.md) | +| Integration | Runs the real CLI binary, asserts on local files and stdout (no AWS creds needed) | [testing/integration-tests.md](testing/integration-tests.md) | +| TUI | Full CLI in a pseudo-terminal — verifies screen output, keyboard navigation, wizard flows | [testing/tui-tests.md](testing/tui-tests.md) | +| Browser | Playwright tests for the agent inspector web UI served by `agentcore dev` | [testing/browser-tests.md](testing/browser-tests.md) | +| E2E | Full user journey across the AWS boundary — deploy, invoke, status, logs, traces | [testing/e2e-tests.md](testing/e2e-tests.md) | -Unit tests are co-located with source files in `__tests__/` directories: +## Manual Testing -``` -src/cli/commands/add/ -├── action.ts -├── command.ts -└── __tests__/ - └── add.test.ts -``` - -### Integration Tests - -Integration tests live in `integ-tests/`: - -``` -integ-tests/ -├── create-no-agent.test.ts -├── create-with-agent.test.ts -├── deploy.test.ts -└── ... -``` - -See [integ-tests/README.md](../integ-tests/README.md) for integration test details. - -### E2E Tests - -E2E tests live in `e2e-tests/` and verify the full user journey across the AWS boundary — deploy, invoke, status, logs, -traces, and control plane API calls. - -``` -e2e-tests/ -├── e2e-helper.ts # Shared utilities and createE2ESuite() factory -├── strands-bedrock.test.ts -├── langgraph-openai.test.ts -└── ... -``` - -See [e2e-tests/README.md](../e2e-tests/README.md) for e2e test details. - -## Writing Tests - -### Imports - -Use vitest for all test utilities: - -```typescript -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -``` - -### Assertions - -Use `expect` assertions: - -```typescript -// Equality -expect(result).toBe('expected'); -expect(obj).toEqual({ key: 'value' }); - -// Truthiness -expect(value).toBeTruthy(); -expect(value).toBeFalsy(); - -// Errors -expect(() => fn()).toThrow(); -expect(() => fn()).toThrow('message'); -``` - -### Mocking - -Use `vi` for mocks: - -```typescript -// Mock functions -const mockFn = vi.fn(); -mockFn.mockReturnValue('value'); -mockFn.mockResolvedValue('async value'); - -// Spies -vi.spyOn(module, 'method'); - -// Module mocks -vi.mock('./module'); -``` - -## Test Utilities - -### CLI Runner - -`src/test-utils/cli-runner.ts` runs CLI commands in tests: - -```typescript -import { runCLI } from '../src/test-utils/cli-runner'; - -const result = await runCLI(['create', '--name', 'test'], tempDir); -expect(result.exitCode).toBe(0); -``` - -## Snapshot Tests - -The `src/assets/` directory contains template files vended to users when they create projects. Snapshot tests ensure -these templates don't change unexpectedly. - -### Running Snapshot Tests - -Snapshot tests run as part of unit tests: - -```bash -npm test # Runs all unit tests including snapshots -npm run test:unit # Same as above -``` - -### Updating Snapshots - -When you intentionally modify asset files (templates, configs, etc.), update snapshots: - -```bash -npm run test:update-snapshots -``` - -Review the changes in `src/assets/__tests__/__snapshots__/` before committing. - -### What's Tested - -- File structure of `src/assets/` -- Contents of all template files (CDK, Python frameworks, MCP, static assets) -- Any file addition or removal - -## TUI Integration Tests - -TUI integration tests run the full CLI binary inside a pseudo-terminal (PTY) and verify screen output, keyboard -navigation, and end-to-end wizard flows. - -> **Note:** TUI tests require `node-pty` (native addon). If node-pty is not installed, TUI tests are automatically -> skipped. - -### Running TUI Tests - -```bash -npm run test:tui # Builds first, then runs TUI tests -npx vitest run --project tui # Skip build (use when build is fresh) -``` - -### Test Organization - -``` -integ-tests/tui/ -├── setup.ts # Global setup: availability check, afterAll cleanup -├── helpers.ts # createMinimalProjectDir, common test setup -├── harness.test.ts # TuiSession self-tests (spawn, send, read) -├── navigation.test.ts # Screen navigation flows -├── create-flow.test.ts # Create wizard end-to-end -├── add-flow.test.ts # Add resource flows -└── deploy-screen.test.ts # Deploy screen rendering -``` - -### Writing a TUI Flow Test - -Below is a complete example showing the typical pattern for a TUI flow test: - -```typescript -import { isAvailable } from '../../src/test-utils/tui-harness/index.js'; -import { TuiSession } from '../../src/test-utils/tui-harness/index.js'; -import { createMinimalProjectDir } from './helpers.js'; -import { afterEach, describe, expect, it } from 'vitest'; - -describe.skipIf(!isAvailable)('my TUI flow', () => { - let session: TuiSession; - - afterEach(async () => { - await session?.close(); - }); - - it('navigates to the add screen', async () => { - // createMinimalProjectDir makes a temp dir with agentcore config (~10ms) - const { dir, cleanup } = await createMinimalProjectDir({ hasAgents: true }); - - try { - // Launch the CLI TUI in the project directory - session = await TuiSession.launch({ - command: 'node', - args: ['../../dist/cli/index.mjs'], - cwd: dir, - }); - - // Wait for the HelpScreen to render - await session.waitFor('Commands'); - - // Navigate: type 'add' to filter, then Enter - await session.sendKeys('add'); - await session.sendSpecialKey('enter'); - - // Verify we reached the AddScreen - await session.waitFor('agent'); - const screen = session.readScreen(); - expect(screen.lines.join('\n')).toContain('agent'); - } finally { - await cleanup(); - } - }); -}); -``` - -Key points: - -- **`describe.skipIf(!isAvailable)`** -- gracefully skips when `node-pty` is missing. -- **`afterEach` with `session?.close()`** -- always clean up PTY processes. -- **`createMinimalProjectDir`** -- fast temp directory setup (no `npm install`). -- **`try/finally` with `cleanup()`** -- always remove temp directories. - -### TuiSession API Quick Reference - -| Method | Returns | Description | -| -------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------- | -| `TuiSession.launch(options)` | `Promise` | Spawn CLI in PTY. Throws `LaunchError` if process exits during startup. | -| `session.sendKeys(text, waitMs?)` | `Promise` | Type text, wait for screen to settle, return screen. | -| `session.sendSpecialKey(key, waitMs?)` | `Promise` | Send special key (enter, tab, escape, etc.), wait, return screen. | -| `session.readScreen(options?)` | `ScreenState` | Read current screen (synchronous). Options: `{ includeScrollback?, numbered? }`. | -| `session.waitFor(pattern, timeoutMs?)` | `Promise` | Wait for text/regex on screen. **Throws `WaitForTimeoutError` on timeout** (default 5000ms). | -| `session.close(signal?)` | `Promise` | Close session. Returns exit code, signal, final screen. | -| `session.info` | `SessionInfo` | Session metadata: sessionId, pid, dimensions, alive status. | -| `session.alive` | `boolean` | Whether the PTY process is still running. | - -### ScreenState Shape - -```typescript -interface ScreenState { - lines: string[]; // Each line of terminal text - cursor: { x: number; y: number }; // Cursor position - dimensions: { cols: number; rows: number }; // Terminal size - bufferType: 'normal' | 'alternate'; // Active buffer -} -``` - -### Special Keys - -The following special keys can be passed to `session.sendSpecialKey()`: - -`enter`, `tab`, `escape`, `backspace`, `delete`, `space`, `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, -`pagedown`, `ctrl+c`, `ctrl+d`, `ctrl+q`, `ctrl+g`, `ctrl+a`, `ctrl+e`, `ctrl+w`, `ctrl+u`, `ctrl+k`, `f1` through -`f12`. - -### Key Concepts - -#### waitFor vs Settling - -- **Settling** (automatic after `sendKeys`/`sendSpecialKey`): Waits for screen text to stop changing. Good for most - screens. Fails on spinner/animation screens because text changes continuously. -- **waitFor**: Polls for a specific text pattern. Use for: (a) async operations with spinners, (b) confirming you - reached the right screen, (c) any case where you need a specific pattern before proceeding. -- **Rule of thumb**: Use `waitFor` when waiting for an async result (project creation, deployment). Use - `sendKeys`/`sendSpecialKey` (which auto-settle) for navigating between static screens. - -#### waitFor Throws on Timeout - -`waitFor()` throws `WaitForTimeoutError` when the pattern is not found within the timeout. The error includes: - -- The pattern that was not found -- How long it waited -- The full screen content at timeout - -This means tests fail fast with useful diagnostics. You do not need to check a `found` boolean. - -#### WaitForTimeoutError Output - -When `waitFor()` times out, the thrown `WaitForTimeoutError` produces a message like this: - -``` -WaitForTimeoutError: waitFor("created successfully") timed out after 5000ms. -Screen content: -AgentCore Create - -Creating project... -⠋ Installing dependencies -``` - -The error message includes the full non-blank screen content at the time of the timeout. This makes it straightforward -to diagnose why the expected pattern was not found -- was the screen still loading? Did the test land on the wrong -screen? Was there a typo in the pattern? - -If you need to inspect the error properties programmatically (for example, to log additional context or make assertions -on the screen state), you can catch the error directly: - -```typescript -import { WaitForTimeoutError } from '../../src/test-utils/tui-harness/index.js'; - -try { - await session.waitFor('expected text', 3000); -} catch (err) { - if (err instanceof WaitForTimeoutError) { - console.log(err.pattern); // 'expected text' - console.log(err.elapsed); // ~3000 - console.log(err.screen); // ScreenState with full content - } - throw err; -} -``` - -#### createMinimalProjectDir - -Creates a temp directory that AgentCore recognizes as a project in ~10ms (no npm install). Use it when your test needs a -project context: - -```typescript -const { dir, cleanup } = await createMinimalProjectDir({ - projectName: 'mytest', // optional, defaults to 'testproject' - hasAgents: true, // optional, adds a sample agent -}); -``` - -Always call `cleanup()` when done (in `finally` or `afterEach`). - -#### LaunchError - -`TuiSession.launch()` throws `LaunchError` when the spawned process exits before the screen settles. Common causes -include a missing binary, a crash on startup, or an invalid working directory. - -The error includes the following diagnostic properties: - -- `command` -- the executable that was launched -- `args` -- the arguments passed to the command -- `cwd` -- the working directory used for the spawned process -- `exitCode` -- the process exit code (or `null` if terminated by signal) -- `screen` -- the `ScreenState` captured at the time of exit - -You can assert that a launch fails with `LaunchError`: - -```typescript -import { LaunchError, TuiSession } from '../../src/test-utils/tui-harness/index.js'; - -it('throws LaunchError for missing binary', async () => { - await expect(TuiSession.launch({ command: 'nonexistent-binary' })).rejects.toThrow(LaunchError); -}); - -// Or if you need to inspect the error: -it('provides diagnostics in LaunchError', async () => { - try { - await TuiSession.launch({ command: 'node', args: ['missing-file.js'] }); - } catch (err) { - if (err instanceof LaunchError) { - console.log(err.command); // 'node' - console.log(err.exitCode); // 1 - console.log(err.screen); // ScreenState at time of crash - } - throw err; - } -}); -``` - -## Browser Tests - -Browser tests use Playwright to test the web UI (agent inspector) served by `agentcore dev`. - -### Prerequisites - -- AWS credentials configured (`aws sts get-caller-identity` must succeed) -- `uv` on PATH -- Local build (`npm run build`) -- Playwright browsers installed: `npx playwright install chromium` - -### Running - -```bash -npm run test:browser -``` - -Test results and the HTML report are written to `browser-tests/test-results/` and `browser-tests/playwright-report/` -respectively. To view the report: - -```bash -npx playwright show-report browser-tests/playwright-report -``` - -By default, tests run against the `@aws/agent-inspector` package from npm (in `node_modules`). - -### Testing against a local agent-inspector build - -To test with a local checkout of the agent-inspector (e.g. when developing new UI features or adding test IDs): - -1. Clone `agent-inspector` as a sibling directory and build it -2. Run with `AGENT_INSPECTOR_PATH`: - -```bash -AGENT_INSPECTOR_PATH=../agent-inspector/dist-assets npm run test:browser -``` - -### Test structure - -``` -browser-tests/ -├── playwright.config.ts # Playwright configuration -├── global-setup.ts # Creates test project, starts agentcore dev -├── global-teardown.ts # Stops dev server, cleans up temp files -├── constants.ts # Shared constants (env file path) -├── fixtures.ts # Custom test fixtures (testEnv with port, project path) -└── tests/ # Test files - ├── chat-invocation.test.ts - ├── inspector-loads.test.ts - ├── resources.test.ts - ├── start-agent.test.ts - └── traces.test.ts -``` - -The global setup creates a temporary project via `agentcore create`, starts `agentcore dev`, and writes connection -details to an env file. Tests read the env file via the `testEnv` fixture. +Every change must be manually tested before submitting. See [testing/manual-testing.md](testing/manual-testing.md) for +instructions on building a local tarball and installing it without conflicting with global installs. ## Configuration @@ -429,33 +35,3 @@ Test configuration is in `vitest.config.ts` using Vitest projects: - **tui** project: `integ-tests/tui/**/*.test.ts` (TUI integration tests) - Test timeout: 120 seconds - Hook timeout: 120 seconds - -## Troubleshooting - -### `Cannot find module '@playwright/test'` - -Playwright is not installed. Run: - -```bash -npm install -``` - -### `browserType.launch: Executable doesn't exist` (Playwright browsers) - -Playwright browsers need to be downloaded after install. Run: - -```bash -npx playwright install chromium -``` - -## Integration Tests - -Integration tests require no AWS credentials. They run the real CLI binary and assert on local files and stdout only. - -Run integration tests: - -```bash -npm run test:integ -``` - -See [integ-tests/README.md](../integ-tests/README.md) for full details. diff --git a/docs/policies/iam-policy-boundary.json b/docs/policies/iam-policy-boundary.json index 751278c00..1a0cb6f8c 100644 --- a/docs/policies/iam-policy-boundary.json +++ b/docs/policies/iam-policy-boundary.json @@ -4,7 +4,7 @@ { "Sid": "BedrockModelAccess", "Effect": "Allow", - "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"], + "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", "bedrock:CountTokens"], "Resource": ["arn:aws:bedrock:*::foundation-model/*", "arn:aws:bedrock:*:ACCOUNT_ID:*"] }, { diff --git a/docs/policies/iam-policy-user.json b/docs/policies/iam-policy-user.json index b7fa29118..38c728542 100644 --- a/docs/policies/iam-policy-user.json +++ b/docs/policies/iam-policy-user.json @@ -116,7 +116,7 @@ { "Sid": "BedrockModelInvocation", "Effect": "Allow", - "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"], + "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", "bedrock:CountTokens"], "Resource": "*" }, { diff --git a/docs/testing/browser-tests.md b/docs/testing/browser-tests.md new file mode 100644 index 000000000..81eb97146 --- /dev/null +++ b/docs/testing/browser-tests.md @@ -0,0 +1,74 @@ +# Browser Tests + +Browser tests use Playwright to test the web UI (agent inspector) served by `agentcore dev`. + +## Prerequisites + +- AWS credentials configured (`aws sts get-caller-identity` must succeed) +- `uv` on PATH +- Local build (`npm run build`) +- Playwright browsers installed: `npx playwright install chromium` + +## Running + +```bash +npm run test:browser +``` + +Test results and the HTML report are written to `browser-tests/test-results/` and `browser-tests/playwright-report/` +respectively. To view the report: + +```bash +npx playwright show-report browser-tests/playwright-report +``` + +By default, tests run against the `@aws/agent-inspector` package from npm (in `node_modules`). + +## Testing against a local agent-inspector build + +To test with a local checkout of the agent-inspector (e.g. when developing new UI features or adding test IDs): + +1. Clone `agent-inspector` as a sibling directory and build it +2. Run with `AGENT_INSPECTOR_PATH`: + +```bash +AGENT_INSPECTOR_PATH=../agent-inspector/dist-assets npm run test:browser +``` + +## Test Structure + +``` +browser-tests/ +├── playwright.config.ts # Playwright configuration +├── global-setup.ts # Creates test project, starts agentcore dev +├── global-teardown.ts # Stops dev server, cleans up temp files +├── constants.ts # Shared constants (env file path) +├── fixtures.ts # Custom test fixtures (testEnv with port, project path) +└── tests/ # Test files + ├── chat-invocation.test.ts + ├── inspector-loads.test.ts + ├── resources.test.ts + ├── start-agent.test.ts + └── traces.test.ts +``` + +The global setup creates a temporary project via `agentcore create`, starts `agentcore dev`, and writes connection +details to an env file. Tests read the env file via the `testEnv` fixture. + +## Troubleshooting + +### `Cannot find module '@playwright/test'` + +Playwright is not installed. Run: + +```bash +npm install +``` + +### `browserType.launch: Executable doesn't exist` (Playwright browsers) + +Playwright browsers need to be downloaded after install. Run: + +```bash +npx playwright install chromium +``` diff --git a/docs/testing/e2e-tests.md b/docs/testing/e2e-tests.md new file mode 100644 index 000000000..50a57803c --- /dev/null +++ b/docs/testing/e2e-tests.md @@ -0,0 +1,29 @@ +# E2E Tests + +E2E tests verify the full user journey across the AWS boundary — deploy, invoke, status, logs, traces, and control plane +API calls. + +## Prerequisites + +- AWS credentials configured (`aws sts get-caller-identity` must succeed) +- Local build (`npm run build`) + +See [e2e-tests/README.md](../../e2e-tests/README.md) for full prerequisite details. + +## Running + +```bash +npm run test:e2e # Run e2e tests +``` + +## Test Organization + +``` +e2e-tests/ +├── e2e-helper.ts # Shared utilities and createE2ESuite() factory +├── strands-bedrock.test.ts +├── langgraph-openai.test.ts +└── ... +``` + +See [e2e-tests/README.md](../../e2e-tests/README.md) for full details. diff --git a/docs/testing/integration-tests.md b/docs/testing/integration-tests.md new file mode 100644 index 000000000..e62d5184f --- /dev/null +++ b/docs/testing/integration-tests.md @@ -0,0 +1,23 @@ +# Integration Tests + +Integration tests require no AWS credentials. They run the real CLI binary and assert on local files and stdout only. + +## Running + +```bash +npm run test:integ # Run integration tests +``` + +## Test Organization + +Integration tests live in `integ-tests/`: + +``` +integ-tests/ +├── create-no-agent.test.ts +├── create-with-agent.test.ts +├── deploy.test.ts +└── ... +``` + +See [integ-tests/README.md](../../integ-tests/README.md) for full details. diff --git a/docs/testing/manual-testing.md b/docs/testing/manual-testing.md new file mode 100644 index 000000000..9b4539581 --- /dev/null +++ b/docs/testing/manual-testing.md @@ -0,0 +1,34 @@ +# Manual Testing + +## Building a local tarball + +Run `npm run bundle` from the agentcore-cli directory. This bundles the CLI along with the CDK constructs from the +sister repo (`agentcore-l3-cdk-constructs`) into a single installable tarball. + +```bash +cd agentcore-cli +npm run bundle +``` + +## Installing locally (without conflicting with global installs) + +Install the tarball into your working directory so it doesn't conflict with other `agentcore` commands on the machine: + +```bash +# From the parent workspace directory +npm init -y # if no package.json exists yet +npm install ./agentcore-cli/aws-agentcore-*.tgz +``` + +Then run it with: + +```bash +npx agentcore +``` + +Or add `node_modules/.bin` to your PATH for this directory only: + +```bash +export PATH="$(pwd)/node_modules/.bin:$PATH" +agentcore +``` diff --git a/docs/testing/tui-tests.md b/docs/testing/tui-tests.md new file mode 100644 index 000000000..1ea6fdeca --- /dev/null +++ b/docs/testing/tui-tests.md @@ -0,0 +1,220 @@ +# TUI Integration Tests + +TUI integration tests run the full CLI binary inside a pseudo-terminal (PTY) and verify screen output, keyboard +navigation, and end-to-end wizard flows. + +> **Note:** TUI tests require `node-pty` (native addon). If node-pty is not installed, TUI tests are automatically +> skipped. + +## Running + +```bash +npm run test:tui # Builds first, then runs TUI tests +npx vitest run --project tui # Skip build (use when build is fresh) +``` + +## Test Organization + +``` +integ-tests/tui/ +├── setup.ts # Global setup: availability check, afterAll cleanup +├── helpers.ts # createMinimalProjectDir, common test setup +├── harness.test.ts # TuiSession self-tests (spawn, send, read) +├── navigation.test.ts # Screen navigation flows +├── create-flow.test.ts # Create wizard end-to-end +├── add-flow.test.ts # Add resource flows +└── deploy-screen.test.ts # Deploy screen rendering +``` + +## Writing a TUI Flow Test + +Below is a complete example showing the typical pattern for a TUI flow test: + +```typescript +import { isAvailable } from '../../src/test-utils/tui-harness/index.js'; +import { TuiSession } from '../../src/test-utils/tui-harness/index.js'; +import { createMinimalProjectDir } from './helpers.js'; +import { afterEach, describe, expect, it } from 'vitest'; + +describe.skipIf(!isAvailable)('my TUI flow', () => { + let session: TuiSession; + + afterEach(async () => { + await session?.close(); + }); + + it('navigates to the add screen', async () => { + // createMinimalProjectDir makes a temp dir with agentcore config (~10ms) + const { dir, cleanup } = await createMinimalProjectDir({ hasAgents: true }); + + try { + // Launch the CLI TUI in the project directory + session = await TuiSession.launch({ + command: 'node', + args: ['../../dist/cli/index.mjs'], + cwd: dir, + }); + + // Wait for the HelpScreen to render + await session.waitFor('Commands'); + + // Navigate: type 'add' to filter, then Enter + await session.sendKeys('add'); + await session.sendSpecialKey('enter'); + + // Verify we reached the AddScreen + await session.waitFor('agent'); + const screen = session.readScreen(); + expect(screen.lines.join('\n')).toContain('agent'); + } finally { + await cleanup(); + } + }); +}); +``` + +Key points: + +- **`describe.skipIf(!isAvailable)`** -- gracefully skips when `node-pty` is missing. +- **`afterEach` with `session?.close()`** -- always clean up PTY processes. +- **`createMinimalProjectDir`** -- fast temp directory setup (no `npm install`). +- **`try/finally` with `cleanup()`** -- always remove temp directories. + +## TuiSession API Quick Reference + +| Method | Returns | Description | +| -------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------- | +| `TuiSession.launch(options)` | `Promise` | Spawn CLI in PTY. Throws `LaunchError` if process exits during startup. | +| `session.sendKeys(text, waitMs?)` | `Promise` | Type text, wait for screen to settle, return screen. | +| `session.sendSpecialKey(key, waitMs?)` | `Promise` | Send special key (enter, tab, escape, etc.), wait, return screen. | +| `session.readScreen(options?)` | `ScreenState` | Read current screen (synchronous). Options: `{ includeScrollback?, numbered? }`. | +| `session.waitFor(pattern, timeoutMs?)` | `Promise` | Wait for text/regex on screen. **Throws `WaitForTimeoutError` on timeout** (default 5000ms). | +| `session.close(signal?)` | `Promise` | Close session. Returns exit code, signal, final screen. | +| `session.info` | `SessionInfo` | Session metadata: sessionId, pid, dimensions, alive status. | +| `session.alive` | `boolean` | Whether the PTY process is still running. | + +## ScreenState Shape + +```typescript +interface ScreenState { + lines: string[]; // Each line of terminal text + cursor: { x: number; y: number }; // Cursor position + dimensions: { cols: number; rows: number }; // Terminal size + bufferType: 'normal' | 'alternate'; // Active buffer +} +``` + +## Special Keys + +The following special keys can be passed to `session.sendSpecialKey()`: + +`enter`, `tab`, `escape`, `backspace`, `delete`, `space`, `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, +`pagedown`, `ctrl+c`, `ctrl+d`, `ctrl+q`, `ctrl+g`, `ctrl+a`, `ctrl+e`, `ctrl+w`, `ctrl+u`, `ctrl+k`, `f1` through +`f12`. + +## Key Concepts + +### waitFor vs Settling + +- **Settling** (automatic after `sendKeys`/`sendSpecialKey`): Waits for screen text to stop changing. Good for most + screens. Fails on spinner/animation screens because text changes continuously. +- **waitFor**: Polls for a specific text pattern. Use for: (a) async operations with spinners, (b) confirming you + reached the right screen, (c) any case where you need a specific pattern before proceeding. +- **Rule of thumb**: Use `waitFor` when waiting for an async result (project creation, deployment). Use + `sendKeys`/`sendSpecialKey` (which auto-settle) for navigating between static screens. + +### waitFor Throws on Timeout + +`waitFor()` throws `WaitForTimeoutError` when the pattern is not found within the timeout. The error includes: + +- The pattern that was not found +- How long it waited +- The full screen content at timeout + +This means tests fail fast with useful diagnostics. You do not need to check a `found` boolean. + +### WaitForTimeoutError Output + +When `waitFor()` times out, the thrown `WaitForTimeoutError` produces a message like this: + +``` +WaitForTimeoutError: waitFor("created successfully") timed out after 5000ms. +Screen content: +AgentCore Create + +Creating project... +⠋ Installing dependencies +``` + +The error message includes the full non-blank screen content at the time of the timeout. This makes it straightforward +to diagnose why the expected pattern was not found -- was the screen still loading? Did the test land on the wrong +screen? Was there a typo in the pattern? + +If you need to inspect the error properties programmatically (for example, to log additional context or make assertions +on the screen state), you can catch the error directly: + +```typescript +import { WaitForTimeoutError } from '../../src/test-utils/tui-harness/index.js'; + +try { + await session.waitFor('expected text', 3000); +} catch (err) { + if (err instanceof WaitForTimeoutError) { + console.log(err.pattern); // 'expected text' + console.log(err.elapsed); // ~3000 + console.log(err.screen); // ScreenState with full content + } + throw err; +} +``` + +### createMinimalProjectDir + +Creates a temp directory that AgentCore recognizes as a project in ~10ms (no npm install). Use it when your test needs a +project context: + +```typescript +const { dir, cleanup } = await createMinimalProjectDir({ + projectName: 'mytest', // optional, defaults to 'testproject' + hasAgents: true, // optional, adds a sample agent +}); +``` + +Always call `cleanup()` when done (in `finally` or `afterEach`). + +### LaunchError + +`TuiSession.launch()` throws `LaunchError` when the spawned process exits before the screen settles. Common causes +include a missing binary, a crash on startup, or an invalid working directory. + +The error includes the following diagnostic properties: + +- `command` -- the executable that was launched +- `args` -- the arguments passed to the command +- `cwd` -- the working directory used for the spawned process +- `exitCode` -- the process exit code (or `null` if terminated by signal) +- `screen` -- the `ScreenState` captured at the time of exit + +You can assert that a launch fails with `LaunchError`: + +```typescript +import { LaunchError, TuiSession } from '../../src/test-utils/tui-harness/index.js'; + +it('throws LaunchError for missing binary', async () => { + await expect(TuiSession.launch({ command: 'nonexistent-binary' })).rejects.toThrow(LaunchError); +}); + +// Or if you need to inspect the error: +it('provides diagnostics in LaunchError', async () => { + try { + await TuiSession.launch({ command: 'node', args: ['missing-file.js'] }); + } catch (err) { + if (err instanceof LaunchError) { + console.log(err.command); // 'node' + console.log(err.exitCode); // 1 + console.log(err.screen); // ScreenState at time of crash + } + throw err; + } +}); +``` diff --git a/docs/testing/unit-tests.md b/docs/testing/unit-tests.md new file mode 100644 index 000000000..6c3a09fd7 --- /dev/null +++ b/docs/testing/unit-tests.md @@ -0,0 +1,107 @@ +# Unit Tests + +Unit tests are co-located with source files in `__tests__/` directories: + +``` +src/cli/commands/add/ +├── action.ts +├── command.ts +└── __tests__/ + └── add.test.ts +``` + +## Running + +```bash +npm test # Run unit tests +npm run test:watch # Run tests in watch mode +npm run test:unit # Same as npm test +``` + +## Writing Tests + +### Imports + +Use vitest for all test utilities: + +```typescript +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +``` + +### Assertions + +Use `expect` assertions: + +```typescript +// Equality +expect(result).toBe('expected'); +expect(obj).toEqual({ key: 'value' }); + +// Truthiness +expect(value).toBeTruthy(); +expect(value).toBeFalsy(); + +// Errors +expect(() => fn()).toThrow(); +expect(() => fn()).toThrow('message'); +``` + +### Mocking + +Use `vi` for mocks: + +```typescript +// Mock functions +const mockFn = vi.fn(); +mockFn.mockReturnValue('value'); +mockFn.mockResolvedValue('async value'); + +// Spies +vi.spyOn(module, 'method'); + +// Module mocks +vi.mock('./module'); +``` + +## Test Utilities + +### CLI Runner + +`src/test-utils/cli-runner.ts` runs CLI commands in tests: + +```typescript +import { runCLI } from '../src/test-utils/cli-runner'; + +const result = await runCLI(['create', '--name', 'test'], tempDir); +expect(result.exitCode).toBe(0); +``` + +## Snapshot Tests + +The `src/assets/` directory contains template files vended to users when they create projects. Snapshot tests ensure +these templates don't change unexpectedly. + +### Running Snapshot Tests + +Snapshot tests run as part of unit tests: + +```bash +npm test # Runs all unit tests including snapshots +npm run test:unit # Same as above +``` + +### Updating Snapshots + +When you intentionally modify asset files (templates, configs, etc.), update snapshots: + +```bash +npm run test:update-snapshots +``` + +Review the changes in `src/assets/__tests__/__snapshots__/` before committing. + +### What's Tested + +- File structure of `src/assets/` +- Contents of all template files (CDK, Python frameworks, MCP, static assets) +- Any file addition or removal diff --git a/integ-tests/add-remove-ab-test.test.ts b/integ-tests/add-remove-ab-test.test.ts index 551c86010..1fd1aa7bc 100644 --- a/integ-tests/add-remove-ab-test.test.ts +++ b/integ-tests/add-remove-ab-test.test.ts @@ -5,8 +5,11 @@ import { readProjectConfig, runCLI, } from '../src/test-utils/index.js'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const telemetry = createTelemetryHelper(); + async function runSuccess(args: string[], cwd: string) { const result = await runCLI(args, cwd); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); @@ -38,6 +41,7 @@ describe('integration: add and remove ab-test', () => { afterAll(async () => { await project.cleanup(); + telemetry.destroy(); }); it('requires --name for JSON mode', async () => { @@ -154,17 +158,26 @@ describe('integration: add and remove ab-test', () => { }); it('removes ab-test', async () => { - const json = await runSuccess(['remove', 'ab-test', '--name', 'MyIntegTest', '--json'], project.projectPath); + const result = await runCLI(['remove', 'ab-test', '--name', 'MyIntegTest', '--json'], project.projectPath, { + env: telemetry.env, + }); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); expect(json.success).toBe(true); - // Verify removal from agentcore.json const spec = await readProjectConfig(project.projectPath); const abTest = spec.abTests?.find((t: { name: string }) => t.name === 'MyIntegTest'); expect(abTest).toBeUndefined(); + telemetry.assertMetricEmitted({ command: 'remove.ab-test', exit_reason: 'success' }); }); it('remove returns error for non-existent test', async () => { - const json = await runFailure(['remove', 'ab-test', '--name', 'DoesNotExist', '--json'], project.projectPath); + const result = await runCLI(['remove', 'ab-test', '--name', 'DoesNotExist', '--json'], project.projectPath, { + env: telemetry.env, + }); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); expect(json.error).toContain('not found'); + telemetry.assertMetricEmitted({ command: 'remove.ab-test', exit_reason: 'failure' }); }); }); diff --git a/integ-tests/add-remove-config-bundle.test.ts b/integ-tests/add-remove-config-bundle.test.ts index bd53e7f31..c6c37c257 100644 --- a/integ-tests/add-remove-config-bundle.test.ts +++ b/integ-tests/add-remove-config-bundle.test.ts @@ -7,10 +7,13 @@ import { runFailure, runSuccess, } from '../src/test-utils/index.js'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const telemetry = createTelemetryHelper(); + describe('integration: add and remove config-bundle', () => { let project: TestProject; @@ -20,6 +23,7 @@ describe('integration: add and remove config-bundle', () => { afterAll(async () => { await project.cleanup(); + telemetry.destroy(); }); // ── Add lifecycle ───────────────────────────────────────────────────── @@ -40,7 +44,7 @@ describe('integration: add and remove config-bundle', () => { expect(json.bundleName).toBe('InlineBundle'); const config = await readProjectConfig(project.projectPath); - const bundle = config.configBundles!.find(b => b.name === 'InlineBundle'); + const bundle = config.configBundles.find(b => b.name === 'InlineBundle'); expect(bundle).toBeDefined(); expect(bundle!.type).toBe('ConfigurationBundle'); expect(bundle!.branchName).toBe('mainline'); @@ -68,7 +72,7 @@ describe('integration: add and remove config-bundle', () => { expect(json.bundleName).toBe('FileBundle'); const config = await readProjectConfig(project.projectPath); - const bundle = config.configBundles!.find(b => b.name === 'FileBundle'); + const bundle = config.configBundles.find(b => b.name === 'FileBundle'); expect(bundle).toBeDefined(); expect(Object.keys(bundle!.components)).toHaveLength(2); }); @@ -102,7 +106,7 @@ describe('integration: add and remove config-bundle', () => { expect(json.bundleName).toBe('FullOptsBundle'); const config = await readProjectConfig(project.projectPath); - const bundle = config.configBundles!.find(b => b.name === 'FullOptsBundle'); + const bundle = config.configBundles.find(b => b.name === 'FullOptsBundle'); expect(bundle).toBeDefined(); expect(bundle!.description).toBe('A bundle with all optional fields'); expect(bundle!.branchName).toBe('feature-branch'); @@ -127,7 +131,7 @@ describe('integration: add and remove config-bundle', () => { expect(json.bundleName).toBe('PlaceholderBundle'); const config = await readProjectConfig(project.projectPath); - const bundle = config.configBundles!.find(b => b.name === 'PlaceholderBundle'); + const bundle = config.configBundles.find(b => b.name === 'PlaceholderBundle'); expect(bundle).toBeDefined(); const keys = Object.keys(bundle!.components); expect(keys).toContain('{{runtime:AgentA}}'); @@ -228,37 +232,45 @@ describe('integration: add and remove config-bundle', () => { describe('remove config-bundle', () => { it('removes an existing config bundle', async () => { - const json = await runSuccess( + const result = await runCLI( ['remove', 'config-bundle', '--name', 'InlineBundle', '--json'], - project.projectPath + project.projectPath, + { env: telemetry.env } ); + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); expect(json.success).toBe(true); const config = await readProjectConfig(project.projectPath); - const bundle = config.configBundles!.find(b => b.name === 'InlineBundle'); + const bundle = config.configBundles.find(b => b.name === 'InlineBundle'); expect(bundle).toBeUndefined(); + telemetry.assertMetricEmitted({ command: 'remove.config-bundle', exit_reason: 'success' }); }); it('returns error for non-existent bundle', async () => { - const json = await runFailure( + const result = await runCLI( ['remove', 'config-bundle', '--name', 'DoesNotExist', '--json'], - project.projectPath + project.projectPath, + { env: telemetry.env } ); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); expect(json.error).toContain('not found'); + telemetry.assertMetricEmitted({ command: 'remove.config-bundle', exit_reason: 'failure' }); }); it('removes all remaining config bundles one by one', async () => { const configBefore = await readProjectConfig(project.projectPath); - const remaining = configBefore.configBundles!.map(b => b.name); + const remaining = configBefore.configBundles.map(b => b.name); for (const name of remaining) { await runSuccess(['remove', 'config-bundle', '--name', name, '--json'], project.projectPath); } const configAfter = await readProjectConfig(project.projectPath); - expect(configAfter.configBundles!).toHaveLength(0); + expect(configAfter.configBundles).toHaveLength(0); }); }); @@ -282,10 +294,10 @@ describe('integration: add and remove config-bundle', () => { } const config = await readProjectConfig(project.projectPath); - expect(config.configBundles!).toHaveLength(bundleNames.length); + expect(config.configBundles).toHaveLength(bundleNames.length); for (const name of bundleNames) { - expect(config.configBundles!.find(b => b.name === name)).toBeDefined(); + expect(config.configBundles.find(b => b.name === name)).toBeDefined(); } }); @@ -293,10 +305,10 @@ describe('integration: add and remove config-bundle', () => { await runSuccess(['remove', 'config-bundle', '--name', 'BundleBeta', '--json'], project.projectPath); const config = await readProjectConfig(project.projectPath); - expect(config.configBundles!).toHaveLength(2); - expect(config.configBundles!.find(b => b.name === 'BundleAlpha')).toBeDefined(); - expect(config.configBundles!.find(b => b.name === 'BundleGamma')).toBeDefined(); - expect(config.configBundles!.find(b => b.name === 'BundleBeta')).toBeUndefined(); + expect(config.configBundles).toHaveLength(2); + expect(config.configBundles.find(b => b.name === 'BundleAlpha')).toBeDefined(); + expect(config.configBundles.find(b => b.name === 'BundleGamma')).toBeDefined(); + expect(config.configBundles.find(b => b.name === 'BundleBeta')).toBeUndefined(); }); afterAll(async () => { diff --git a/integ-tests/add-remove-evaluator.test.ts b/integ-tests/add-remove-evaluator.test.ts index 30c3917c3..cee29f858 100644 --- a/integ-tests/add-remove-evaluator.test.ts +++ b/integ-tests/add-remove-evaluator.test.ts @@ -1,10 +1,13 @@ import { createTestProject, parseJsonOutput, readProjectConfig, runCLI } from '../src/test-utils/index.js'; import type { TestProject } from '../src/test-utils/index.js'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const telemetry = createTelemetryHelper(); + /** Run a CLI command and assert it succeeds, returning parsed JSON output. */ async function runSuccess(args: string[], cwd: string) { - const result = await runCLI(args, cwd); + const result = await runCLI(args, cwd, { env: telemetry.env }); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); const json: unknown = parseJsonOutput(result.stdout); expect(json).toHaveProperty('success', true); @@ -13,7 +16,7 @@ async function runSuccess(args: string[], cwd: string) { /** Run a CLI command and assert it fails, returning parsed JSON output. */ async function runFailure(args: string[], cwd: string) { - const result = await runCLI(args, cwd); + const result = await runCLI(args, cwd, { env: telemetry.env }); expect(result.exitCode).toBe(1); const json: unknown = parseJsonOutput(result.stdout); expect(json).toHaveProperty('success', false); @@ -35,6 +38,7 @@ describe('integration: add and remove evaluators and online eval configs', () => afterAll(async () => { await project.cleanup(); + telemetry.destroy(); }); describe('evaluator and online eval lifecycle', () => { @@ -123,6 +127,7 @@ describe('integration: add and remove evaluators and online eval configs', () => const config = await readProjectConfig(project.projectPath); expect(config.onlineEvalConfigs.find(c => c.name === configName)).toBeUndefined(); + telemetry.assertMetricEmitted({ command: 'remove.online-eval', exit_reason: 'success' }); }); it('removes the evaluator after online eval is gone', async () => { @@ -130,6 +135,7 @@ describe('integration: add and remove evaluators and online eval configs', () => const config = await readProjectConfig(project.projectPath); expect(config.evaluators.find(e => e.name === evalName)).toBeUndefined(); + telemetry.assertMetricEmitted({ command: 'remove.evaluator', exit_reason: 'success' }); }); }); @@ -137,11 +143,13 @@ describe('integration: add and remove evaluators and online eval configs', () => it('fails to remove non-existent evaluator', async () => { const json = await runFailure(['remove', 'evaluator', '--name', 'NonExistent', '--json'], project.projectPath); expect(json.error).toContain('not found'); + telemetry.assertMetricEmitted({ command: 'remove.evaluator', exit_reason: 'failure' }); }); it('fails to remove non-existent online eval config', async () => { const json = await runFailure(['remove', 'online-eval', '--name', 'NonExistent', '--json'], project.projectPath); expect(json.error).toContain('not found'); + telemetry.assertMetricEmitted({ command: 'remove.online-eval', exit_reason: 'failure' }); }); it('rejects evaluator with missing --level', async () => { diff --git a/integ-tests/add-remove-gateway.test.ts b/integ-tests/add-remove-gateway.test.ts index ba4753c9b..8453c5e60 100644 --- a/integ-tests/add-remove-gateway.test.ts +++ b/integ-tests/add-remove-gateway.test.ts @@ -1,9 +1,12 @@ import { createTestProject, runCLI } from '../src/test-utils/index.js'; import type { TestProject } from '../src/test-utils/index.js'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const telemetry = createTelemetryHelper(); + async function readProjectConfig(projectPath: string) { return JSON.parse(await readFile(join(projectPath, 'agentcore/agentcore.json'), 'utf-8')); } @@ -19,6 +22,7 @@ describe('integration: add and remove gateway with external MCP server', () => { afterAll(async () => { await project.cleanup(); + telemetry.destroy(); }); describe('gateway lifecycle', () => { @@ -64,7 +68,9 @@ describe('integration: add and remove gateway with external MCP server', () => { }); it('removes the gateway target', async () => { - const result = await runCLI(['remove', 'gateway-target', '--name', targetName, '--json'], project.projectPath); + const result = await runCLI(['remove', 'gateway-target', '--name', targetName, '--json'], project.projectPath, { + env: telemetry.env, + }); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); const json = JSON.parse(result.stdout); @@ -75,10 +81,13 @@ describe('integration: add and remove gateway with external MCP server', () => { const targets = gateway?.targets ?? []; const found = targets.find((t: { name: string }) => t.name === targetName); expect(found, `Target "${targetName}" should be removed`).toBeFalsy(); + telemetry.assertMetricEmitted({ command: 'remove.gateway-target', exit_reason: 'success' }); }); it('removes the gateway', async () => { - const result = await runCLI(['remove', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + const result = await runCLI(['remove', 'gateway', '--name', gatewayName, '--json'], project.projectPath, { + env: telemetry.env, + }); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); const json = JSON.parse(result.stdout); @@ -88,6 +97,25 @@ describe('integration: add and remove gateway with external MCP server', () => { const gateways = mcpSpec.agentCoreGateways ?? []; const found = gateways.find((g: { name: string }) => g.name === gatewayName); expect(found, `Gateway "${gatewayName}" should be removed`).toBeFalsy(); + telemetry.assertMetricEmitted({ command: 'remove.gateway', exit_reason: 'success' }); + }); + + it('fails to remove non-existent gateway', async () => { + const result = await runCLI(['remove', 'gateway', '--name', 'NonExistent', '--json'], project.projectPath, { + env: telemetry.env, + }); + expect(result.exitCode).toBe(1); + telemetry.assertMetricEmitted({ command: 'remove.gateway', exit_reason: 'failure' }); + }); + + it('fails to remove non-existent gateway target', async () => { + const result = await runCLI( + ['remove', 'gateway-target', '--name', 'NonExistent', '--json'], + project.projectPath, + { env: telemetry.env } + ); + expect(result.exitCode).toBe(1); + telemetry.assertMetricEmitted({ command: 'remove.gateway-target', exit_reason: 'failure' }); }); }); }); diff --git a/integ-tests/add-remove-policy.test.ts b/integ-tests/add-remove-policy.test.ts index 2830b792a..a44f51c5e 100644 --- a/integ-tests/add-remove-policy.test.ts +++ b/integ-tests/add-remove-policy.test.ts @@ -1,7 +1,10 @@ import { createTestProject, readProjectConfig, runCLI } from '../src/test-utils/index.js'; import type { TestProject } from '../src/test-utils/index.js'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const telemetry = createTelemetryHelper(); + describe('integration: add and remove policy engines and policies', () => { let project: TestProject; @@ -16,6 +19,7 @@ describe('integration: add and remove policy engines and policies', () => { afterAll(async () => { await project.cleanup(); + telemetry.destroy(); }); describe('policy engine lifecycle', () => { @@ -111,7 +115,8 @@ describe('integration: add and remove policy engines and policies', () => { it('removes a policy with --engine flag', async () => { const result = await runCLI( ['remove', 'policy', '--name', policyName, '--engine', engineName, '--json'], - project.projectPath + project.projectPath, + { env: telemetry.env } ); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); @@ -125,6 +130,7 @@ describe('integration: add and remove policy engines and policies', () => { expect(engine, `Engine "${engineName}" should still exist`).toBeDefined(); const policy = engine!.policies.find(p => p.name === policyName); expect(policy, `Policy "${policyName}" should be removed`).toBeUndefined(); + telemetry.assertMetricEmitted({ command: 'remove.policy', exit_reason: 'success' }); }); }); @@ -272,24 +278,29 @@ describe('integration: add and remove policy engines and policies', () => { }); it('fails to remove non-existent policy', async () => { - const result = await runCLI(['remove', 'policy', '--name', 'NonExistentPolicy', '--json'], project.projectPath); + const result = await runCLI(['remove', 'policy', '--name', 'NonExistentPolicy', '--json'], project.projectPath, { + env: telemetry.env, + }); expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); expect(json.success).toBe(false); expect(json.error).toContain('not found'); + telemetry.assertMetricEmitted({ command: 'remove.policy', exit_reason: 'failure' }); }); it('fails to remove non-existent policy engine', async () => { const result = await runCLI( ['remove', 'policy-engine', '--name', 'NonExistentEngine', '--json'], - project.projectPath + project.projectPath, + { env: telemetry.env } ); expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); expect(json.success).toBe(false); expect(json.error).toContain('not found'); + telemetry.assertMetricEmitted({ command: 'remove.policy-engine', exit_reason: 'failure' }); }); it('requires --engine when adding a policy', async () => { diff --git a/integ-tests/add-remove-resources.test.ts b/integ-tests/add-remove-resources.test.ts index a89c761dd..19d607481 100644 --- a/integ-tests/add-remove-resources.test.ts +++ b/integ-tests/add-remove-resources.test.ts @@ -41,7 +41,6 @@ describe('integration: add and remove resources', () => { const found = memories!.some((m: Record) => m.name === memoryName); expect(found, `Memory "${memoryName}" should be in config`).toBe(true); - // Verify telemetry telemetry.assertMetricEmitted({ command: 'add.memory', exit_reason: 'success' }); }); @@ -71,7 +70,6 @@ describe('integration: add and remove resources', () => { expect(episodic!.reflectionNamespaces, 'Should have reflectionNamespaces').toBeDefined(); expect(episodic!.reflectionNamespaces!.length).toBeGreaterThan(0); - // Verify telemetry telemetry.assertMetricEmitted({ command: 'add.memory', exit_reason: 'success', @@ -84,7 +82,9 @@ describe('integration: add and remove resources', () => { }); it('removes the memory resource', async () => { - const result = await runCLI(['remove', 'memory', '--name', memoryName, '--json'], project.projectPath); + const result = await runCLI(['remove', 'memory', '--name', memoryName, '--json'], project.projectPath, { + env: telemetry.env, + }); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); const json = JSON.parse(result.stdout); @@ -95,6 +95,8 @@ describe('integration: add and remove resources', () => { const memories = (config.memories as Record[] | undefined) ?? []; const found = memories.some((m: Record) => m.name === memoryName); expect(found, `Memory "${memoryName}" should be removed from config`).toBe(false); + + telemetry.assertMetricEmitted({ command: 'remove.memory', exit_reason: 'success' }); }); }); @@ -119,7 +121,6 @@ describe('integration: add and remove resources', () => { const found = credentials!.some((c: Record) => c.name === credentialName); expect(found, `Credential "${credentialName}" should be in config`).toBe(true); - // Verify telemetry telemetry.assertMetricEmitted({ command: 'add.credential', exit_reason: 'success', @@ -128,7 +129,9 @@ describe('integration: add and remove resources', () => { }); it('removes the credential resource', async () => { - const result = await runCLI(['remove', 'credential', '--name', credentialName, '--json'], project.projectPath); + const result = await runCLI(['remove', 'credential', '--name', credentialName, '--json'], project.projectPath, { + env: telemetry.env, + }); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); const json = JSON.parse(result.stdout); @@ -139,6 +142,8 @@ describe('integration: add and remove resources', () => { const credentials = (config.credentials as Record[] | undefined) ?? []; const found = credentials.some((c: Record) => c.name === credentialName); expect(found, `Credential "${credentialName}" should be removed from config`).toBe(false); + + telemetry.assertMetricEmitted({ command: 'remove.credential', exit_reason: 'success' }); }); }); @@ -162,9 +167,46 @@ describe('integration: add and remove resources', () => { }); it('removes the policy engine resource', async () => { - const result = await runCLI(['remove', 'policy-engine', '--name', engineName, '--json'], project.projectPath); + const result = await runCLI(['remove', 'policy-engine', '--name', engineName, '--json'], project.projectPath, { + env: telemetry.env, + }); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + + telemetry.assertMetricEmitted({ command: 'remove.policy-engine', exit_reason: 'success' }); + }); + }); + + describe('remove failure telemetry', () => { + it('emits failure telemetry for non-existent memory', async () => { + const result = await runCLI(['remove', 'memory', '--name', 'DoesNotExist', '--json'], project.projectPath, { + env: telemetry.env, + }); + + expect(result.exitCode).toBe(1); + telemetry.assertMetricEmitted({ command: 'remove.memory', exit_reason: 'failure' }); + }); + + it('emits failure telemetry for non-existent credential', async () => { + const result = await runCLI(['remove', 'credential', '--name', 'DoesNotExist', '--json'], project.projectPath, { + env: telemetry.env, + }); + + expect(result.exitCode).toBe(1); + telemetry.assertMetricEmitted({ command: 'remove.credential', exit_reason: 'failure' }); + }); + }); + + describe('remove all', () => { + it('resets all schemas and emits telemetry', async () => { + const result = await runCLI(['remove', 'all', '--yes', '--json'], project.projectPath, { + env: telemetry.env, + }); + + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + telemetry.assertMetricEmitted({ command: 'remove.all', exit_reason: 'success' }); }); }); }); diff --git a/package-lock.json b/package-lock.json index c789baec9..69d83fe40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,7 +77,7 @@ "lint-staged": "^16.2.7", "node-pty": "^1.1.0", "prettier": "^3.7.4", - "secretlint": "^12.2.0", + "secretlint": "^13.0.0", "tsx": "^4.21.0", "typescript": "^5", "typescript-eslint": "^8.50.1", @@ -2139,13 +2139,13 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.6.tgz", - "integrity": "sha512-8Vu7zGxu+39ChR/s5J7nXBw3a2kMHAi0OfKT8ohgTVjX0qYed/8mIfdBb638oBmKrWCwwKjYAM5J/4gMJ8nAJA==", + "version": "3.974.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", + "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.20", + "@aws-sdk/xml-builder": "^3.972.22", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", @@ -2155,7 +2155,7 @@ "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2889,13 +2889,14 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz", - "integrity": "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", + "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", - "fast-xml-parser": "5.5.8", + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.2", "tslib": "^2.6.2" }, "engines": { @@ -4012,6 +4013,18 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4881,29 +4894,29 @@ "license": "MIT" }, "node_modules/@secretlint/config-creator": { - "version": "12.3.1", - "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-12.3.1.tgz", - "integrity": "sha512-CCRvPfrQLt2fPg3eWTIDGXNcVFQd6ZnvQCZ5lzclV9OF7iRqXQ4l5lfGO8NS68tIZx7YvBKhcO8/eVxdqm89HA==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-13.0.0.tgz", + "integrity": "sha512-HJLoVUqXPSxu1s7b2TSjCIeScOHxD4hvPYysJUOqnKqEs/ZqXwC9uFpRo8qS79cVwe8s4lF+OoLmXnMCyMMnxQ==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "12.3.1" + "@secretlint/types": "13.0.0" }, "engines": { "node": ">=22.0.0" } }, "node_modules/@secretlint/config-loader": { - "version": "12.3.1", - "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-12.3.1.tgz", - "integrity": "sha512-PNrxz8tnAU/y5PmfOtKfVb+zEA3I+1iZqP1f9fXvIBtauBKs0h0Y+Cmvj0gG1a34kxaD1aQvFh8qHEhRV05gWg==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-13.0.0.tgz", + "integrity": "sha512-fWRE9aMZ4AHzDNaJNKX1P0W1IS08fFLe0c17jKq8BYhrlCehl2II6HqQXmef5no92SBpAEMLuL0uVAdfrtdigQ==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/profiler": "12.3.1", - "@secretlint/resolver": "12.3.1", - "@secretlint/types": "12.3.1", - "ajv": "^8.18.0", + "@secretlint/profiler": "13.0.0", + "@secretlint/resolver": "13.0.0", + "@secretlint/types": "13.0.0", + "ajv": "^8.20.0", "debug": "^4.4.3", "rc-config-loader": "^4.1.4" }, @@ -4912,14 +4925,14 @@ } }, "node_modules/@secretlint/core": { - "version": "12.3.1", - "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-12.3.1.tgz", - "integrity": "sha512-ulcfARo1TANr8tWzDO/5cFxSNEEfRzgW6YPHYUijgpH3iYfwtUhWEU/r/BiFGl2PNaGzzVE1N9A6nZ74xbYvUQ==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-13.0.0.tgz", + "integrity": "sha512-j7Yt1o7sgFsN19N0Q4CGmLOdvf0PG3DE5xMYAoQ42i+h4/7Ui3HfCKcDAycDp4aXDiJAJvluko64GY0yDobAVg==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/profiler": "12.3.1", - "@secretlint/types": "12.3.1", + "@secretlint/profiler": "13.0.0", + "@secretlint/types": "13.0.0", "debug": "^4.4.3", "structured-source": "^4.0.0" }, @@ -4928,17 +4941,17 @@ } }, "node_modules/@secretlint/formatter": { - "version": "12.3.1", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-12.3.1.tgz", - "integrity": "sha512-SXpTiRzuuFNbHa59zk0eUxFOB/LYxnuHSfqq7zU9lIt0z5rox6NrnN9WWkoQai2V9s7n3VqUVUpZqnhxQ2Jzpw==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-13.0.0.tgz", + "integrity": "sha512-OOskMV3r7yJQFo+0PvrRalRYKwMa2kdx9hrMiO9RYshPGn+155084j0uIziburXhdQmRhhrCe+KFln87KHm5pQ==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/resolver": "12.3.1", - "@secretlint/types": "12.3.1", - "@textlint/linter-formatter": "^15.5.4", - "@textlint/module-interop": "^15.5.4", - "@textlint/types": "^15.5.4", + "@secretlint/resolver": "13.0.0", + "@secretlint/types": "13.0.0", + "@textlint/linter-formatter": "^15.6.0", + "@textlint/module-interop": "^15.6.0", + "@textlint/types": "^15.6.0", "chalk": "^5.6.2", "debug": "^4.4.3", "pluralize": "^8.0.0", @@ -4964,18 +4977,18 @@ } }, "node_modules/@secretlint/node": { - "version": "12.3.1", - "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-12.3.1.tgz", - "integrity": "sha512-1T08nqwWIJqSRrfkebk4Op5MwYgNnB6gwjv9v+X+V+HEIeG1GB/EgH8CJa8jK4uYdhUuaKyXpu36FIbjNa1wqA==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-13.0.0.tgz", + "integrity": "sha512-256dKkIHB4cXXBT9Cr/tcwf1n3IxCo5sk8Q9z4QXDux+IAUKjRxyk3c02LcJXgTc3GTrZGh+F1s17vUo3qJG+g==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-loader": "12.3.1", - "@secretlint/core": "12.3.1", - "@secretlint/formatter": "12.3.1", - "@secretlint/profiler": "12.3.1", - "@secretlint/source-creator": "12.3.1", - "@secretlint/types": "12.3.1", + "@secretlint/config-loader": "13.0.0", + "@secretlint/core": "13.0.0", + "@secretlint/formatter": "13.0.0", + "@secretlint/profiler": "13.0.0", + "@secretlint/source-creator": "13.0.0", + "@secretlint/types": "13.0.0", "debug": "^4.4.3", "p-map": "^7.0.4" }, @@ -4984,16 +4997,16 @@ } }, "node_modules/@secretlint/profiler": { - "version": "12.3.1", - "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-12.3.1.tgz", - "integrity": "sha512-lztyqJPTfkY0Ze9P7vNs3zm7p2Wq1+4ilFXVrxin0sDyFVXpkt+0+vsKsmdx9yBHabxrLDZgxa7fIsfV721cLw==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-13.0.0.tgz", + "integrity": "sha512-jQb/UBs0kgbzGGeQ+i4LuSSHvPwscp5Ge9UC4/izxNi3guwBTkE/bsREvbEaGR80qtn8fmWZytP88oWYMMrN2g==", "dev": true, "license": "MIT" }, "node_modules/@secretlint/resolver": { - "version": "12.3.1", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-12.3.1.tgz", - "integrity": "sha512-/QwcX5azKRdz9mBIbTBUsqp+cmWQZYGNdOHLbsMOBTLXa7KoEBffhmeaMSc0kNSrdgbgfu/7j+qeeaF4QwJf3A==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-13.0.0.tgz", + "integrity": "sha512-5mUTxb+CPl/589Efq5L4SBqJ2yDguKNVZ7udCPiQasI9jS0h1P/CBWfqp+eoKlSoEggu2RKbMHdRiidRfnRXQQ==", "dev": true, "license": "MIT" }, @@ -5008,13 +5021,13 @@ } }, "node_modules/@secretlint/source-creator": { - "version": "12.3.1", - "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-12.3.1.tgz", - "integrity": "sha512-RCkmyKdoe6VFWMzzVm5a9W+a+ptJSusVX+YOrcNy/heklMIWLg0bL+HYFcyYCm8rU2dRq2HuSYTOamDjNs0LZg==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-13.0.0.tgz", + "integrity": "sha512-+whDtfudstqU0/rt787EDdqvNCna/c2REw9BOHYAcylZ2tpyL35gi0z7byUcqu4aJcI1z5epG7APcM0nd50PGg==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "12.3.1", + "@secretlint/types": "13.0.0", "istextorbinary": "^9.5.0" }, "engines": { @@ -5022,26 +5035,27 @@ } }, "node_modules/@secretlint/types": { - "version": "12.3.1", - "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-12.3.1.tgz", - "integrity": "sha512-Qv3fKvPkzUJpS9Ps6m2EPjC0RdxS2ZZrRfZAhIdl2u0zSjgf+Z0+AaCngmHRR+3Vtbw6s2FrCf4T6mLirm8Hgg==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-13.0.0.tgz", + "integrity": "sha512-C0tVelPjw8UHNeUgAMMWq85EWEItL+evpICrOezhM+jETYUYTNdnQ8dcEOuFJrevojQBxY2ygeHPiJoqkWPmNA==", "dev": true, "license": "MIT", "engines": { "node": ">=22.0.0" } }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "node_modules/@secretlint/walker": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@secretlint/walker/-/walker-13.0.0.tgz", + "integrity": "sha512-Q7+yhgKvSUA7dS8J8asO/04JfmvUnQRW9eB9CrgTmGY7m6klAHkgAyNaBw/JAe5ISxQes379su3xxa8TS9AZ/g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "ignore": "^7.0.5", + "picomatch": "^4.0.4" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=22.0.0" } }, "node_modules/@smithy/chunked-blob-reader": { @@ -6737,9 +6751,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -9459,13 +9473,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -9542,9 +9556,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -9558,9 +9572,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", - "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "funding": [ { "type": "github", @@ -9569,13 +9583,14 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.1.3" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, "node_modules/fast-xml-parser": { - "version": "5.5.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", - "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", "funding": [ { "type": "github", @@ -9584,9 +9599,10 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.1.3", - "strnum": "^2.2.0" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -9961,27 +9977,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", - "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.5", - "is-path-inside": "^4.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -10164,9 +10159,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "dev": true, "license": "MIT", "engines": { @@ -10561,9 +10556,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", "engines": { @@ -10895,19 +10890,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-path-inside": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", - "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -14390,19 +14372,19 @@ "license": "MIT" }, "node_modules/secretlint": { - "version": "12.3.1", - "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-12.3.1.tgz", - "integrity": "sha512-wv8TKCjU5hbBxo5jKEX8wIE78VAoL0Ux7pu18+TxtbICMZ2OCbu6EmO3OJLbUbyfUXSPVryNLNmGVgvwY6Z0xw==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-13.0.0.tgz", + "integrity": "sha512-bc4dzeaYMiuorNAb4cPoOX87gb+w0kiReZaUEx9iu8u0/PrkMj5QCm6pXji/NDc4Z6HUZO5ihfd0HY/4iPzskQ==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-creator": "12.3.1", - "@secretlint/formatter": "12.3.1", - "@secretlint/node": "12.3.1", - "@secretlint/profiler": "12.3.1", - "@secretlint/resolver": "12.3.1", + "@secretlint/config-creator": "13.0.0", + "@secretlint/formatter": "13.0.0", + "@secretlint/node": "13.0.0", + "@secretlint/profiler": "13.0.0", + "@secretlint/resolver": "13.0.0", + "@secretlint/walker": "13.0.0", "debug": "^4.4.3", - "globby": "^16.2.0", "read-pkg": "^10.1.0" }, "bin": { @@ -14637,19 +14619,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", @@ -15062,9 +15031,9 @@ } }, "node_modules/strnum": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", - "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "funding": [ { "type": "github", @@ -16357,9 +16326,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -16828,6 +16797,21 @@ } } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 004e2dc29..c1134abd3 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,7 @@ "lint-staged": "^16.2.7", "node-pty": "^1.1.0", "prettier": "^3.7.4", - "secretlint": "^12.2.0", + "secretlint": "^13.0.0", "tsx": "^4.21.0", "typescript": "^5", "typescript-eslint": "^8.50.1", @@ -149,14 +149,10 @@ }, "overridesComments": { "minimatch": "GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74: minimatch 10.0.0-10.2.2 has ReDoS vulnerabilities. Multiple transitive deps (eslint, typescript-eslint, eslint-plugin-import, eslint-plugin-react, prettier-plugin-sort-imports, aws-cdk-lib) pin older versions. Remove this override once upstream packages update their minimatch dependency to >=10.2.3.", - "fast-xml-parser": "GHSA-8gc5-j5rx-235r, GHSA-jp2q-39xq-3w4g: fast-xml-parser <=5.5.6 has entity expansion bypass (CVE-2026-33036, CVE-2026-33349). Transitive via @aws-sdk/xml-builder. Remove once @aws-sdk updates to fast-xml-parser >=5.5.7.", - "@aws-sdk/xml-builder": "aws/aws-sdk-js-v3#7867: @aws-sdk/xml-builder <3.972.14 does not configure maxTotalExpansions on fast-xml-parser, causing 'Entity expansion limit exceeded' on large CloudFormation responses. Remove once @aws-sdk/client-* deps are bumped past 3.972.14.", "glob": "glob <12 is deprecated and emits npm install warnings (https://github.com/isaacs/node-glob). Pulled in transitively via archiver-utils@5.0.2 (latest), which still pins glob@^10.0.0. archiver-utils only uses glob.sync(pattern, options), which remains compatible in glob@13. Remove this override once archiver-utils updates its glob dependency." }, "overrides": { "minimatch": "10.2.4", - "fast-xml-parser": "5.5.7", - "@aws-sdk/xml-builder": "3.972.15", "glob": "^13.0.0" }, "engines": { diff --git a/src/cli/__tests__/update-notifier.test.ts b/src/cli/__tests__/update-notifier.test.ts index 3713e51f2..28f0d90ef 100644 --- a/src/cli/__tests__/update-notifier.test.ts +++ b/src/cli/__tests__/update-notifier.test.ts @@ -1,107 +1,83 @@ +import { ONE_DAY_MS, ONE_HOUR_MS, ONE_SECOND_MS } from '../../lib/time-constants.js'; +import * as action from '../commands/update/action.js'; +import * as constants from '../constants.js'; import { type UpdateCheckResult, checkForUpdate, printUpdateNotification } from '../update-notifier.js'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const { mockReadFile, mockWriteFile, mockMkdir } = vi.hoisted(() => ({ - mockReadFile: vi.fn(), - mockWriteFile: vi.fn(), - mockMkdir: vi.fn(), -})); - -vi.mock('fs/promises', () => ({ - readFile: mockReadFile, - writeFile: mockWriteFile, - mkdir: mockMkdir, -})); - -vi.mock('../constants.js', () => ({ - PACKAGE_VERSION: '1.0.0', -})); - -const { mockFetchLatestVersion, mockCompareVersions } = vi.hoisted(() => ({ - mockFetchLatestVersion: vi.fn(), - mockCompareVersions: vi.fn(), -})); - -vi.mock('../commands/update/action.js', () => ({ - fetchLatestVersion: mockFetchLatestVersion, - compareVersions: mockCompareVersions, -})); +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { mkdir, readFile, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const NOW = 1708646400000; +const tmpDir = mkdtempSync(join(tmpdir(), 'update-notifier-test-')); +const CACHE_FILE = join(tmpDir, 'update-check.json'); describe('checkForUpdate', () => { + let originalConfigDir: string | undefined; + beforeEach(() => { - vi.spyOn(Date, 'now').mockReturnValue(1708646400000); - mockWriteFile.mockResolvedValue(undefined); - mockMkdir.mockResolvedValue(undefined); + originalConfigDir = process.env.AGENTCORE_CONFIG_DIR; + process.env.AGENTCORE_CONFIG_DIR = tmpDir; + vi.spyOn(Date, 'now').mockReturnValue(NOW); + vi.spyOn(constants, 'PACKAGE_VERSION', 'get').mockReturnValue('1.0.0'); + rmSync(tmpDir, { recursive: true, force: true }); }); afterEach(() => { vi.restoreAllMocks(); - mockReadFile.mockReset(); - mockWriteFile.mockReset(); - mockMkdir.mockReset(); - mockFetchLatestVersion.mockReset(); - mockCompareVersions.mockReset(); + if (originalConfigDir === undefined) { + delete process.env.AGENTCORE_CONFIG_DIR; + } else { + process.env.AGENTCORE_CONFIG_DIR = originalConfigDir; + } + }); + + afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); }); it('fetches from registry when no cache exists', async () => { - mockReadFile.mockRejectedValue(new Error('ENOENT')); - mockFetchLatestVersion.mockResolvedValue('2.0.0'); - mockCompareVersions.mockReturnValue(1); + vi.spyOn(action, 'fetchLatestVersion').mockResolvedValue('2.0.0'); const result = await checkForUpdate(); expect(result).toEqual({ updateAvailable: true, latestVersion: '2.0.0' }); - expect(mockFetchLatestVersion).toHaveBeenCalled(); }); it('uses cache when last check was less than 24 hours ago', async () => { - const cache = JSON.stringify({ - lastCheck: 1708646400000 - 1000, // 1 second ago - latestVersion: '2.0.0', - }); - mockReadFile.mockResolvedValue(cache); - mockCompareVersions.mockReturnValue(1); + await mkdir(tmpDir, { recursive: true }); + await writeFile(CACHE_FILE, JSON.stringify({ lastCheck: NOW - ONE_SECOND_MS, latestVersion: '2.0.0' }), 'utf-8'); const result = await checkForUpdate(); expect(result).toEqual({ updateAvailable: true, latestVersion: '2.0.0' }); - expect(mockFetchLatestVersion).not.toHaveBeenCalled(); }); it('fetches from registry when cache is expired', async () => { - const cache = JSON.stringify({ - lastCheck: 1708646400000 - 25 * 60 * 60 * 1000, // 25 hours ago - latestVersion: '1.5.0', - }); - mockReadFile.mockResolvedValue(cache); - mockFetchLatestVersion.mockResolvedValue('2.0.0'); - mockCompareVersions.mockReturnValue(1); + await mkdir(tmpDir, { recursive: true }); + await writeFile( + CACHE_FILE, + JSON.stringify({ lastCheck: NOW - ONE_DAY_MS - ONE_HOUR_MS, latestVersion: '1.5.0' }), + 'utf-8' + ); + vi.spyOn(action, 'fetchLatestVersion').mockResolvedValue('2.0.0'); const result = await checkForUpdate(); expect(result).toEqual({ updateAvailable: true, latestVersion: '2.0.0' }); - expect(mockFetchLatestVersion).toHaveBeenCalled(); }); it('writes cache after fetching', async () => { - mockReadFile.mockRejectedValue(new Error('ENOENT')); - mockFetchLatestVersion.mockResolvedValue('2.0.0'); - mockCompareVersions.mockReturnValue(1); + vi.spyOn(action, 'fetchLatestVersion').mockResolvedValue('2.0.0'); await checkForUpdate(); - expect(mockMkdir).toHaveBeenCalled(); - expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining('update-check.json'), - JSON.stringify({ lastCheck: 1708646400000, latestVersion: '2.0.0' }), - 'utf-8' - ); + const cached = JSON.parse(await readFile(CACHE_FILE, 'utf-8')); + expect(cached).toEqual({ lastCheck: NOW, latestVersion: '2.0.0' }); }); it('returns updateAvailable: false when versions match', async () => { - mockReadFile.mockRejectedValue(new Error('ENOENT')); - mockFetchLatestVersion.mockResolvedValue('1.0.0'); - mockCompareVersions.mockReturnValue(0); + vi.spyOn(action, 'fetchLatestVersion').mockResolvedValue('1.0.0'); const result = await checkForUpdate(); @@ -109,9 +85,7 @@ describe('checkForUpdate', () => { }); it('returns updateAvailable: false when current is newer', async () => { - mockReadFile.mockRejectedValue(new Error('ENOENT')); - mockFetchLatestVersion.mockResolvedValue('0.9.0'); - mockCompareVersions.mockReturnValue(-1); + vi.spyOn(action, 'fetchLatestVersion').mockResolvedValue('0.9.0'); const result = await checkForUpdate(); @@ -119,8 +93,7 @@ describe('checkForUpdate', () => { }); it('returns null on fetch error', async () => { - mockReadFile.mockRejectedValue(new Error('ENOENT')); - mockFetchLatestVersion.mockRejectedValue(new Error('network error')); + vi.spyOn(action, 'fetchLatestVersion').mockRejectedValue(new Error('network error')); const result = await checkForUpdate(); @@ -128,8 +101,9 @@ describe('checkForUpdate', () => { }); it('returns null on cache parse error and fetch error', async () => { - mockReadFile.mockResolvedValue('invalid json'); - mockFetchLatestVersion.mockRejectedValue(new Error('network error')); + await mkdir(tmpDir, { recursive: true }); + await writeFile(CACHE_FILE, 'invalid json', 'utf-8'); + vi.spyOn(action, 'fetchLatestVersion').mockRejectedValue(new Error('network error')); const result = await checkForUpdate(); @@ -137,10 +111,14 @@ describe('checkForUpdate', () => { }); it('succeeds even when cache write fails', async () => { - mockReadFile.mockRejectedValue(new Error('ENOENT')); - mockFetchLatestVersion.mockResolvedValue('2.0.0'); - mockCompareVersions.mockReturnValue(1); - mockWriteFile.mockRejectedValue(new Error('EACCES')); + // Point config dir at a regular file — mkdir/writeFile will fail because + // a file can't be used as a directory. Works cross-platform and as root. + mkdirSync(tmpDir, { recursive: true }); + const blocker = join(tmpDir, 'not-a-dir'); + writeFileSync(blocker, ''); + process.env.AGENTCORE_CONFIG_DIR = blocker; + + vi.spyOn(action, 'fetchLatestVersion').mockResolvedValue('2.0.0'); const result = await checkForUpdate(); @@ -157,7 +135,6 @@ describe('printUpdateNotification', () => { const output = stderrSpy.mock.calls.map(c => c[0]).join(''); expect(output).toContain('Update available:'); - expect(output).toContain('1.0.0'); expect(output).toContain('2.0.0'); expect(output).toContain('npm install -g @aws/agentcore@latest'); diff --git a/src/cli/commands/import/phase2-import.ts b/src/cli/commands/import/phase2-import.ts index d898785f3..c980110bd 100644 --- a/src/cli/commands/import/phase2-import.ts +++ b/src/cli/commands/import/phase2-import.ts @@ -1,3 +1,4 @@ +import { PollExhaustedError, PollTimeoutError, isThrottlingError, poll } from '../../../lib/utils/polling'; import { getCredentialProvider } from '../../aws/account'; import type { CfnTemplate } from './template-utils'; import { buildImportTemplate } from './template-utils'; @@ -141,64 +142,61 @@ async function waitForChangeSetReady( stackName: string, changeSetName: string ): Promise { - const maxAttempts = 60; - const delay = 5000; // 5 seconds - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const response = await cfn.send( - new DescribeChangeSetCommand({ - StackName: stackName, - ChangeSetName: changeSetName, - }) - ); - - const status = response.Status; - - if (status === 'CREATE_COMPLETE') { - return; - } - - if (status === 'FAILED') { - throw new Error(`Change set creation failed: ${response.StatusReason ?? 'Unknown reason'}`); + try { + await poll({ + fn: async () => { + const response = await cfn.send( + new DescribeChangeSetCommand({ + StackName: stackName, + ChangeSetName: changeSetName, + }) + ); + const status = response.Status; + if (status === 'CREATE_COMPLETE') return { done: true, value: undefined }; + if (status === 'FAILED') { + throw new Error(`Change set creation failed: ${response.StatusReason ?? 'Unknown reason'}`); + } + return { done: false }; + }, + maxAttempts: 60, + delayMs: 5000, + onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'), + }); + } catch (err) { + if (err instanceof PollExhaustedError || err instanceof PollTimeoutError) { + throw new Error('Timed out waiting for change set creation', { cause: err }); } - - // CREATE_PENDING, CREATE_IN_PROGRESS — keep waiting - await new Promise(resolve => setTimeout(resolve, delay)); + throw err; } - - throw new Error('Timed out waiting for change set creation'); } /** * Wait for stack to reach IMPORT_COMPLETE status. */ async function waitForStackImportComplete(cfn: CloudFormationClient, stackName: string): Promise { - const maxAttempts = 120; - const delay = 5000; // 5 seconds - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const response = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); - const stack = response.Stacks?.[0]; - - if (!stack) { - throw new Error(`Stack ${stackName} not found during import wait`); - } - - const status = stack.StackStatus ?? ''; - - if (status === 'IMPORT_COMPLETE') { - return; - } - - if (status.includes('FAILED') || status.includes('ROLLBACK')) { - throw new Error(`Import failed with status: ${status}. Reason: ${stack.StackStatusReason ?? 'Unknown'}`); + try { + await poll({ + fn: async () => { + const response = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); + const stack = response.Stacks?.[0]; + if (!stack) throw new Error(`Stack ${stackName} not found during import wait`); + const status = stack.StackStatus ?? ''; + if (status === 'IMPORT_COMPLETE') return { done: true, value: undefined }; + if (status.includes('FAILED') || status.includes('ROLLBACK')) { + throw new Error(`Import failed with status: ${status}. Reason: ${stack.StackStatusReason ?? 'Unknown'}`); + } + return { done: false }; + }, + maxAttempts: 120, + delayMs: 5000, + onError: (err: unknown) => (isThrottlingError(err) ? 'retry' : 'abort'), + }); + } catch (err) { + if (err instanceof PollExhaustedError || err instanceof PollTimeoutError) { + throw new Error('Timed out waiting for import to complete', { cause: err }); } - - // IMPORT_IN_PROGRESS — keep waiting - await new Promise(resolve => setTimeout(resolve, delay)); + throw err; } - - throw new Error('Timed out waiting for import to complete'); } /** diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 05e532688..da1951d19 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -1,5 +1,6 @@ import { ConfigIO } from '../../../lib'; import { getErrorMessage } from '../../errors'; +import { runCliCommand } from '../../telemetry/cli-command-run.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; import { RemoveAllScreen, RemoveFlow } from '../../tui/screens/remove'; @@ -54,9 +55,12 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise { validateRemoveAllOptions(options); - const result = await handleRemoveAll(options); - console.log(JSON.stringify(result)); - process.exit(result.success ? 0 : 1); + await runCliCommand('remove.all', !!options.json, async () => { + const result = await handleRemoveAll(options); + if (!result.success) throw new Error(result.error); + console.log(JSON.stringify(result)); + return {}; + }); } export const registerRemove = (program: Command): Command => { diff --git a/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts index 39a32d20f..75f36ebcc 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts @@ -298,6 +298,67 @@ describe('setupABTests', () => { expect(mockCreateABTest.mock.calls[0]![0].evaluationConfig.onlineEvaluationConfigArn).toBe('arn:eval:resolved'); }); + + it('resolves target-based variant with project prefix (runtime === projectName)', async () => { + const targetBasedTest = { + ...sampleABTest, + mode: 'target-based' as const, + variants: [ + { + name: 'C' as const, + weight: 90, + variantConfiguration: { target: { targetName: 'MyAgent-prod' } }, + }, + { + name: 'T1' as const, + weight: 10, + variantConfiguration: { target: { targetName: 'MyAgent-staging' } }, + }, + ], + }; + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-tgt-1', abTestArn: 'arn:abt:tgt1' }); + + await setupABTests({ + region: 'us-east-1', + projectSpec: makeProjectSpec([targetBasedTest]), + }); + + const callArgs = mockCreateABTest.mock.calls[0]![0]; + expect(callArgs.variants[0].variantConfiguration.target.name).toBe('TestProject-MyAgent-prod'); + expect(callArgs.variants[1].variantConfiguration.target.name).toBe('TestProject-MyAgent-staging'); + }); + + it('resolves target-based variant with project prefix (different project name)', async () => { + const targetBasedTest = { + ...sampleABTest, + mode: 'target-based' as const, + variants: [ + { + name: 'C' as const, + weight: 90, + variantConfiguration: { target: { targetName: 'Bar-prod' } }, + }, + { + name: 'T1' as const, + weight: 10, + variantConfiguration: { target: { targetName: 'Bar-staging' } }, + }, + ], + }; + mockCreateABTest.mockResolvedValue({ abTestId: 'abt-tgt-2', abTestArn: 'arn:abt:tgt2' }); + + const spec = makeProjectSpec([targetBasedTest]); + spec.name = 'Foo'; + + await setupABTests({ + region: 'us-east-1', + projectSpec: spec, + }); + + const callArgs = mockCreateABTest.mock.calls[0]![0]; + expect(callArgs.variants[0].variantConfiguration.target.name).toBe('Foo-Bar-prod'); + expect(callArgs.variants[1].variantConfiguration.target.name).toBe('Foo-Bar-staging'); + }); }); describe('deletion (reconciliation)', () => { diff --git a/src/cli/operations/deploy/post-deploy-ab-tests.ts b/src/cli/operations/deploy/post-deploy-ab-tests.ts index d4c6d4314..9c5481d93 100644 --- a/src/cli/operations/deploy/post-deploy-ab-tests.ts +++ b/src/cli/operations/deploy/post-deploy-ab-tests.ts @@ -476,14 +476,11 @@ function resolveConfigBundleVersion( } /** - * Resolve a variant target name, applying the project prefix if not already present. - * This handles legacy configs that were created before the prefix requirement. + * Resolve a variant target name by applying the project prefix unconditionally. + * post-deploy-http-gateways.ts always creates targets as `${projectName}-${tgt.name}`, + * so the AB test must reference the same prefixed name. */ function resolveTargetName(targetName: string, projectName: string): string { - // If the target name already starts with the project prefix, use as-is to avoid double-prefixing - if (targetName.startsWith(`${projectName}-`)) { - return targetName; - } return `${projectName}-${targetName}`; } diff --git a/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts b/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts index 710281293..6308a1f24 100644 --- a/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts +++ b/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts @@ -1,62 +1,50 @@ import { resolveUIDistDir } from '../web-server.js'; import fs from 'node:fs'; -import path from 'node:path'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('node:fs'); - -const existsSync = vi.mocked(fs.existsSync); - describe('resolveUIDistDir', () => { const originalEnv = process.env; + let tmpDir: string; beforeEach(() => { process.env = { ...originalEnv }; + tmpDir = mkdtempSync(join(tmpdir(), 'resolve-ui-test-')); delete process.env.AGENT_INSPECTOR_PATH; - existsSync.mockReturnValue(false); }); afterEach(() => { process.env = originalEnv; + rmSync(tmpDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); it('returns null when no candidate has index.html', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + expect(resolveUIDistDir()).toBeNull(); }); it('returns AGENT_INSPECTOR_PATH when env var is set and dir has index.html', () => { - const customPath = '/custom/inspector/dist'; - process.env.AGENT_INSPECTOR_PATH = customPath; - - existsSync.mockImplementation(p => p === path.join(customPath, 'index.html')); + process.env.AGENT_INSPECTOR_PATH = tmpDir; + writeFileSync(join(tmpDir, 'index.html'), ''); - expect(resolveUIDistDir()).toBe(customPath); + expect(resolveUIDistDir()).toBe(tmpDir); }); it('skips AGENT_INSPECTOR_PATH when env var is set but dir lacks index.html', () => { - process.env.AGENT_INSPECTOR_PATH = '/missing/inspector'; - existsSync.mockReturnValue(false); + process.env.AGENT_INSPECTOR_PATH = tmpDir; + vi.spyOn(fs, 'existsSync').mockReturnValue(false); expect(resolveUIDistDir()).toBeNull(); }); - it('returns the first candidate that has index.html', () => { - existsSync.mockImplementation(p => { - return String(p).endsWith(path.join('agent-inspector', 'index.html')); - }); - - const result = resolveUIDistDir(); - expect(result).not.toBeNull(); - expect(result!).toMatch(/agent-inspector$/); - }); - it('prefers AGENT_INSPECTOR_PATH over bundled candidates', () => { - const customPath = '/custom/path'; - process.env.AGENT_INSPECTOR_PATH = customPath; - - existsSync.mockReturnValue(true); + process.env.AGENT_INSPECTOR_PATH = tmpDir; + writeFileSync(join(tmpDir, 'index.html'), ''); - expect(resolveUIDistDir()).toBe(customPath); + expect(resolveUIDistDir()).toBe(tmpDir); }); }); diff --git a/src/cli/operations/traces/__tests__/get-trace.test.ts b/src/cli/operations/traces/__tests__/get-trace.test.ts index c6fda22f4..8f833cc0a 100644 --- a/src/cli/operations/traces/__tests__/get-trace.test.ts +++ b/src/cli/operations/traces/__tests__/get-trace.test.ts @@ -1,6 +1,9 @@ import { fetchTraceRecords, getTrace } from '../get-trace'; import type { FetchTraceRecordsOptions } from '../types'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn(), @@ -22,13 +25,6 @@ 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', @@ -183,11 +179,18 @@ describe('fetchTraceRecords', () => { }); describe('getTrace', () => { - afterEach(() => vi.clearAllMocks()); + let tmpDir: string; - it('calls fetchTraceRecords and writes result to disk', async () => { - const fs = await import('node:fs'); + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'get-trace-test-')); + }); + + afterEach(() => { + vi.clearAllMocks(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + it('calls fetchTraceRecords and writes result to disk', async () => { mockSend.mockResolvedValueOnce({ queryId: 'q-1' }).mockResolvedValueOnce({ status: 'Complete', results: [ @@ -198,36 +201,38 @@ describe('getTrace', () => { ], }); + const outputPath = join(tmpDir, 'test-trace.json'); const result = await getTrace({ region: 'us-west-2', runtimeId: 'runtime-123', agentName: 'my-agent', traceId: 'abc123def456', - outputPath: '/tmp/test-trace.json', + outputPath, 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"')); + const content = JSON.parse(readFileSync(outputPath, 'utf-8')); + expect(content).toHaveLength(1); + expect(content[0]['@message']).toEqual({ traceId: 'abc123' }); }); it('returns error from fetchTraceRecords without writing file', async () => { - const fs = await import('node:fs'); - + const outputPath = join(tmpDir, 'should-not-exist.json'); const result = await getTrace({ region: 'us-west-2', runtimeId: 'runtime-123', agentName: 'my-agent', traceId: 'invalid!@#$', + outputPath, startTime: 1000000, endTime: 2000000, }); expect(result.success).toBe(false); expect(result.error).toContain('Invalid trace ID format'); - expect(fs.default.writeFileSync).not.toHaveBeenCalled(); + expect(existsSync(outputPath)).toBe(false); }); }); diff --git a/src/cli/primitives/ABTestPrimitive.ts b/src/cli/primitives/ABTestPrimitive.ts index 9dd973571..dc1852c1f 100644 --- a/src/cli/primitives/ABTestPrimitive.ts +++ b/src/cli/primitives/ABTestPrimitive.ts @@ -3,6 +3,7 @@ import type { ABTest } from '../../schema/schemas/primitives/ab-test'; import { ABTestSchema } from '../../schema/schemas/primitives/ab-test'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; import { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; @@ -468,7 +469,9 @@ Target-Based Mode (--mode target-based) process.exit(1); } - const result = await this.remove(cliOptions.name, { deleteGateway: cliOptions.deleteGateway }); + const result = await withCommandRunTelemetry('remove.ab-test', {}, () => + this.remove(cliOptions.name!, { deleteGateway: cliOptions.deleteGateway }) + ); console.log( JSON.stringify({ success: result.success, diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index b9873990b..283e91ce2 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -35,7 +35,7 @@ import { import { executeImportAgent } from '../operations/agent/import'; import { setupPythonProject } from '../operations/python'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; -import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { runCliCommand } from '../telemetry/cli-command-run.js'; import { AgentType, AuthorizerType, @@ -283,7 +283,7 @@ export class AgentPrimitive extends BasePrimitive { + await runCliCommand('add.agent', !!cliOptions.json, async () => { const validation = validateAddAgentOptions(cliOptions); if (!validation.valid) { throw new Error(validation.error); diff --git a/src/cli/primitives/BasePrimitive.ts b/src/cli/primitives/BasePrimitive.ts index 149611c4f..4738a7140 100644 --- a/src/cli/primitives/BasePrimitive.ts +++ b/src/cli/primitives/BasePrimitive.ts @@ -2,6 +2,8 @@ import { ConfigIO, findConfigRoot } from '../../lib'; import type { AgentCoreProjectSpec } from '../../schema'; import type { ResourceType } from '../commands/remove/types'; import { getErrorMessage } from '../errors'; +import { withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; +import type { SubCommand } from '../telemetry/schemas/command-run.js'; import { requireTTY } from '../tui/guards/tty'; import { SOURCE_CODE_NOTE } from './constants'; import type { AddResult, AddScreenComponent, RemovableResource, RemovalPreview, RemovalResult } from './types'; @@ -120,7 +122,11 @@ export abstract class BasePrimitive< process.exit(1); } - const result = await this.remove(cliOptions.name); + const result = await withCommandRunTelemetry, RemovalResult>( + `remove.${this.kind}`, + {}, + () => this.remove(cliOptions.name!) + ); console.log( JSON.stringify({ success: result.success, diff --git a/src/cli/primitives/CredentialPrimitive.tsx b/src/cli/primitives/CredentialPrimitive.tsx index 9607094f8..17d77c458 100644 --- a/src/cli/primitives/CredentialPrimitive.tsx +++ b/src/cli/primitives/CredentialPrimitive.tsx @@ -4,7 +4,7 @@ import { CredentialSchema } from '../../schema'; import { validateAddCredentialOptions } from '../commands/add/validate'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; -import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { runCliCommand } from '../telemetry/cli-command-run.js'; import { CredentialType, standardize } from '../telemetry/schemas/common-shapes.js'; import { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; @@ -291,7 +291,7 @@ export class CredentialPrimitive extends BasePrimitive { + await runCliCommand('add.credential', !!cliOptions.json, async () => { const validation = validateAddCredentialOptions({ name: cliOptions.name, type: cliOptions.type as 'api-key' | 'oauth' | undefined, diff --git a/src/cli/primitives/EvaluatorPrimitive.ts b/src/cli/primitives/EvaluatorPrimitive.ts index ec7372b08..ed255c061 100644 --- a/src/cli/primitives/EvaluatorPrimitive.ts +++ b/src/cli/primitives/EvaluatorPrimitive.ts @@ -3,7 +3,7 @@ import type { EvaluationLevel, Evaluator, EvaluatorConfig } from '../../schema'; import { EvaluationLevelSchema, EvaluatorSchema } from '../../schema'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; -import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { runCliCommand } from '../telemetry/cli-command-run.js'; import { EvaluatorType, Level, standardize } from '../telemetry/schemas/common-shapes.js'; import { renderCodeBasedEvaluatorTemplate } from '../templates/EvaluatorRenderer'; import { requireTTY } from '../tui/guards/tty'; @@ -204,7 +204,7 @@ export class EvaluatorPrimitive extends BasePrimitive { + await runCliCommand('add.evaluator', !!cliOptions.json, async () => { const fail = (error: string): never => { throw new Error(error); }; diff --git a/src/cli/primitives/GatewayPrimitive.ts b/src/cli/primitives/GatewayPrimitive.ts index 625a3c4a5..9710ec076 100644 --- a/src/cli/primitives/GatewayPrimitive.ts +++ b/src/cli/primitives/GatewayPrimitive.ts @@ -12,7 +12,7 @@ import type { AddGatewayOptions as CLIAddGatewayOptions } from '../commands/add/ import { validateAddGatewayOptions } from '../commands/add/validate'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; -import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { runCliCommand, withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; import { AuthorizerType, PolicyEngineMode, standardize } from '../telemetry/schemas/common-shapes.js'; import { requireTTY } from '../tui/guards/tty'; import type { AddGatewayConfig } from '../tui/screens/mcp/types'; @@ -188,7 +188,7 @@ export class GatewayPrimitive extends BasePrimitive { + await runCliCommand('add.gateway', !!cliOptions.json, async () => { const validation = validateAddGatewayOptions(cliOptions); if (!validation.valid) { throw new Error(validation.error); @@ -262,7 +262,7 @@ export class GatewayPrimitive extends BasePrimitive this.remove(cliOptions.name!)); console.log( JSON.stringify({ success: result.success, diff --git a/src/cli/primitives/GatewayTargetPrimitive.ts b/src/cli/primitives/GatewayTargetPrimitive.ts index e8a1da996..2a33b011d 100644 --- a/src/cli/primitives/GatewayTargetPrimitive.ts +++ b/src/cli/primitives/GatewayTargetPrimitive.ts @@ -14,7 +14,7 @@ import { validateAddGatewayTargetOptions } from '../commands/add/validate'; import { getErrorMessage } from '../errors'; import type { RemovableGatewayTarget } from '../operations/remove/remove-gateway-target'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; -import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { runCliCommand, withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; import { GATEWAY_TARGET_TYPE_MAP, GatewayTargetHost, @@ -309,7 +309,7 @@ export class GatewayTargetPrimitive extends BasePrimitive { + await runCliCommand('add.gateway-target', !!cliOptions.json, async () => { const validation = await validateAddGatewayTargetOptions(cliOptions); if (!validation.valid) { throw new Error(validation.error); @@ -510,7 +510,9 @@ export class GatewayTargetPrimitive extends BasePrimitive + this.remove(cliOptions.name!) + ); console.log( JSON.stringify({ success: result.success, diff --git a/src/cli/primitives/MemoryPrimitive.tsx b/src/cli/primitives/MemoryPrimitive.tsx index a0c15c2c3..fd893a36e 100644 --- a/src/cli/primitives/MemoryPrimitive.tsx +++ b/src/cli/primitives/MemoryPrimitive.tsx @@ -17,7 +17,7 @@ import { import { DEFAULT_DELIVERY_TYPE, validateAddMemoryOptions } from '../commands/add/validate'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; -import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { runCliCommand } from '../telemetry/cli-command-run.js'; import { requireTTY } from '../tui/guards/tty'; import { DEFAULT_EVENT_EXPIRY } from '../tui/screens/memory/types'; import { BasePrimitive } from './BasePrimitive'; @@ -191,7 +191,7 @@ export class MemoryPrimitive extends BasePrimitive { + await runCliCommand('add.memory', !!cliOptions.json, async () => { const expiry = cliOptions.expiry ? parseInt(cliOptions.expiry, 10) : undefined; const validation = validateAddMemoryOptions({ name: cliOptions.name, diff --git a/src/cli/primitives/OnlineEvalConfigPrimitive.ts b/src/cli/primitives/OnlineEvalConfigPrimitive.ts index 6e4436ac9..0b77cbba5 100644 --- a/src/cli/primitives/OnlineEvalConfigPrimitive.ts +++ b/src/cli/primitives/OnlineEvalConfigPrimitive.ts @@ -3,7 +3,7 @@ import type { OnlineEvalConfig } from '../../schema'; import { OnlineEvalConfigSchema } from '../../schema'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; -import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { runCliCommand } from '../telemetry/cli-command-run.js'; import { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; @@ -134,7 +134,7 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive { + await runCliCommand('add.online-eval', !!cliOptions.json, async () => { if (!cliOptions.name || !cliOptions.runtime || allEvaluators.length === 0 || !cliOptions.samplingRate) { throw new Error( '--name, --runtime, --evaluator (and/or --evaluator-arn), and --sampling-rate are all required in non-interactive mode' diff --git a/src/cli/primitives/PolicyEnginePrimitive.ts b/src/cli/primitives/PolicyEnginePrimitive.ts index bb9b314d8..bab91022a 100644 --- a/src/cli/primitives/PolicyEnginePrimitive.ts +++ b/src/cli/primitives/PolicyEnginePrimitive.ts @@ -3,7 +3,7 @@ import type { AgentCoreProjectSpec, PolicyEngine } from '../../schema'; import { PolicyEngineModeSchema, PolicyEngineSchema } from '../../schema'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; -import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { runCliCommand, withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; import { AttachMode, standardize } from '../telemetry/schemas/common-shapes.js'; import { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; @@ -229,7 +229,7 @@ export class PolicyEnginePrimitive extends BasePrimitive { + await runCliCommand('add.policy-engine', !!cliOptions.json, async () => { if (!cliOptions.name) { throw new Error('--name is required'); } @@ -316,7 +316,9 @@ export class PolicyEnginePrimitive extends BasePrimitive + this.remove(cliOptions.name!) + ); if (cliOptions.json) { console.log( JSON.stringify({ diff --git a/src/cli/primitives/PolicyPrimitive.ts b/src/cli/primitives/PolicyPrimitive.ts index beefe3008..e9d3b6914 100644 --- a/src/cli/primitives/PolicyPrimitive.ts +++ b/src/cli/primitives/PolicyPrimitive.ts @@ -5,7 +5,7 @@ import { detectRegion } from '../aws'; import { getPolicyGeneration, startPolicyGeneration } from '../aws/policy-generation'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; -import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { runCliCommand, withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; import { ValidationMode, standardize } from '../telemetry/schemas/common-shapes.js'; import { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; @@ -306,7 +306,7 @@ export class PolicyPrimitive extends BasePrimitive { + await runCliCommand('add.policy', !!cliOptions.json, async () => { if (!cliOptions.name) { throw new Error('--name is required'); } @@ -400,7 +400,7 @@ export class PolicyPrimitive extends BasePrimitive this.remove(removeKey)); if (cliOptions.json) { console.log( diff --git a/src/cli/primitives/RuntimeEndpointPrimitive.ts b/src/cli/primitives/RuntimeEndpointPrimitive.ts index e055fd17a..ae2ae83b1 100644 --- a/src/cli/primitives/RuntimeEndpointPrimitive.ts +++ b/src/cli/primitives/RuntimeEndpointPrimitive.ts @@ -4,7 +4,7 @@ import { RuntimeEndpointSchema } from '../../schema'; import type { ResourceType } from '../commands/remove/types'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; -import { cliCommandRun } from '../telemetry/cli-command-run.js'; +import { runCliCommand, withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; import { BasePrimitive } from './BasePrimitive'; import { SOURCE_CODE_NOTE } from './constants'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; @@ -254,7 +254,7 @@ export class RuntimeEndpointPrimitive extends BasePrimitive { + await runCliCommand('add.runtime-endpoint', !!cliOptions.json, async () => { const result = await this.add({ runtime: cliOptions.runtime, endpoint: cliOptions.endpoint, @@ -296,7 +296,9 @@ export class RuntimeEndpointPrimitive extends BasePrimitive + this.remove(cliOptions.name!) + ); console.log( JSON.stringify({ success: result.success, diff --git a/src/cli/telemetry/README.md b/src/cli/telemetry/README.md new file mode 100644 index 000000000..7d83ef6dc --- /dev/null +++ b/src/cli/telemetry/README.md @@ -0,0 +1,104 @@ +# Adding New Telemetry Metrics + +## Overview + +Every CLI command emits a `command_run` metric with a command key, exit reason, and command-specific attributes. This +guide shows how to add telemetry to a new command. + +## Step 1: Register the command in `schemas/command-run.ts` + +Add an entry to `COMMAND_SCHEMAS`: + +```ts +// No attributes: +'remove.widget': NoAttrs, + +// With attributes: +'add.widget': safeSchema({ + widget_type: WidgetType, // z.enum(), z.boolean(), z.number(), or z.literal() only + count: Count, +}), +``` + +`safeSchema` enforces allowed field types at compile time. No `z.string()` fields. + +## Step 2: Add enums to `schemas/common-shapes.ts` + +```ts +export const WidgetType = z.enum(['basic', 'advanced']); +``` + +Use `standardize()` to normalize input before recording: + +```ts +import { WidgetType, standardize } from '../telemetry/schemas/common-shapes.js'; + +const type = standardize(WidgetType, userInput); +``` + +## Step 3: Instrument the command handler + +Use **`withCommandRunTelemetry`** — the primary helper for recording telemetry: + +```ts +import { withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; + +const result = await withCommandRunTelemetry('remove.gateway', {}, () => this.remove(name)); +``` + +**Signature:** + +```ts +async function withCommandRunTelemetry( + command: C, + attrs: CommandAttrs, + fn: () => Promise +): Promise; +``` + +- `command` — the registered command key (e.g. `'add.widget'`) +- `attrs` — attribute object matching the schema registered in Step 1 +- `fn` — async callback returning `{ success: true } | { success: false; error: string }` + +**Behavior:** + +- On success (`{ success: true }`): records success telemetry with `attrs`, returns the result. +- On failure (`{ success: false, error }`): records failure telemetry, returns the result to the caller. +- On throw: records failure telemetry, returns `{ success: false, error }` so callers don't leak unhandled rejections. +- If telemetry is unavailable: runs `fn()` untracked. + +**Example with attributes:** + +```ts +const result = await withCommandRunTelemetry( + 'add.widget', + { widget_type: standardize(WidgetType, config.type), count: config.items.length }, + () => widgetPrimitive.add(config) +); + +if (!result.success) { + console.error(result.error); + process.exit(1); +} +``` + +### `runCliCommand` (alternative for top-level CLI handlers) + +For CLI handlers that own `process.exit`, use `runCliCommand` instead. The callback throws on failure and returns attrs +on success: + +```ts +await runCliCommand('add.widget', !!options.json, async () => { + const result = await widgetPrimitive.add(options); + if (!result.success) throw new Error(result.error); + return { widget_type: standardize(WidgetType, options.type), count: options.items.length }; +}); +``` + +## Key Points + +- Telemetry never crashes the CLI — `standardize()` falls back gracefully, `resilientParse` defaults invalid fields to + `'unknown'`. +- Prefer `withCommandRunTelemetry` for new code — it returns the `Result` for the caller to handle output and control + flow. +- Use `runCliCommand` only when the handler owns `process.exit` and prints its own output. diff --git a/src/cli/telemetry/cli-command-run.ts b/src/cli/telemetry/cli-command-run.ts index 987f05730..f04347dc5 100644 --- a/src/cli/telemetry/cli-command-run.ts +++ b/src/cli/telemetry/cli-command-run.ts @@ -1,30 +1,69 @@ import { getErrorMessage } from '../errors'; -import type { AddResult } from '../primitives/types.js'; import { TelemetryClientAccessor } from './client-accessor.js'; import type { Command, CommandAttrs } from './schemas/command-run.js'; +// TODO: Replace with a generic Result type that preserves the original error object. +export type OperationResult = { success: true } | { success: false; error: string }; + +async function getTelemetryClient() { + try { + return await TelemetryClientAccessor.get(); + } catch { + return undefined; + } +} + /** - * Run a CLI command with telemetry, standardized error output, and process.exit. - * The callback should throw on failure and return telemetry attrs on success. + * Record telemetry for an operation and return its result. + * Use in TUI hooks and CLI paths where the caller handles output and control flow. * - * If telemetry initialization fails, the command still runs without telemetry — - * telemetry must never block CLI behavior. + * If the callback returns a failure result, telemetry is recorded and the result + * is returned to the caller. If the callback throws, telemetry is recorded and + * the exception propagates. If telemetry is unavailable, the callback runs untracked. */ -export async function cliCommandRun( +export async function withCommandRunTelemetry( + command: C, + attrs: CommandAttrs, + fn: () => Promise +): Promise { + const client = await getTelemetryClient(); + if (!client) return fn(); + + let result: R | undefined; + try { + await client.withCommandRun(command, async () => { + result = await fn(); + if (!result.success) throw new Error(result.error); + return attrs; + }); + } catch (e) { + // withCommandRun re-throws after recording failure telemetry. + // If result was set, fn() returned a failure result — return it directly. + // If not, fn() itself threw — convert to a failure result so callers + // that don't wrap in try/catch (e.g. TUI hooks) don't leak unhandled rejections. + if (!result) { + return { success: false, error: getErrorMessage(e) } as R; + } + } + return result!; +} + +/** + * Record telemetry, print errors, and exit the process. + * Use in CLI command handlers where the command is the final action. + * The callback returns attrs on success and throws on failure. + */ +export async function runCliCommand( command: C, json: boolean, fn: () => Promise> ): Promise { try { - let client; - try { - client = await TelemetryClientAccessor.get(); - } catch { - // Telemetry init failed — run without it + const client = await getTelemetryClient(); + if (!client) { await fn(); process.exit(0); } - // withCommandRun records success/failure telemetry, then re-throws on failure await client.withCommandRun(command, fn); process.exit(0); } catch (error) { @@ -36,36 +75,3 @@ export async function cliCommandRun( process.exit(1); } } - -/** - * Wrap a primitive .add() call with telemetry — used by TUI paths. - * CLI paths use {@link cliCommandRun} instead. - */ -export async function withAddTelemetry>( - command: C, - attrs: CommandAttrs, - fn: () => Promise> -): Promise> { - let client; - try { - client = await TelemetryClientAccessor.get(); - } catch { - return fn(); - } - - let result: AddResult | undefined; - try { - await client.withCommandRun(command, async () => { - result = await fn(); - if (!result.success) throw new Error(result.error); - return attrs; - }); - } catch (err) { - // withCommandRun re-throws after recording failure telemetry. - // result is set if fn() ran; if not, fn() itself threw. - if (!result) { - return { success: false, error: getErrorMessage(err) }; - } - } - return result!; -} diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index 0acfcaf1b..7bcc4e555 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -195,6 +195,7 @@ export const COMMAND_SCHEMAS = { validate: NoAttrs, 'help.modes': NoAttrs, help: NoAttrs, + 'remove.all': NoAttrs, 'remove.agent': NoAttrs, 'remove.memory': NoAttrs, 'remove.credential': NoAttrs, @@ -204,6 +205,9 @@ export const COMMAND_SCHEMAS = { 'remove.gateway-target': NoAttrs, 'remove.policy-engine': NoAttrs, 'remove.policy': NoAttrs, + 'remove.runtime-endpoint': NoAttrs, + 'remove.config-bundle': NoAttrs, + 'remove.ab-test': NoAttrs, 'telemetry.disable': NoAttrs, 'telemetry.enable': NoAttrs, 'telemetry.status': NoAttrs, @@ -216,6 +220,22 @@ export const COMMAND_SCHEMAS = { export type Command = keyof typeof COMMAND_SCHEMAS; export type CommandAttrs = z.infer<(typeof COMMAND_SCHEMAS)[C]>; +/** Extract the command group prefix from a dotted command key (e.g. 'add' from 'add.agent'). */ +type CommandGroup = { + [C in Command]: C extends `${infer G}.${string}` ? G : C; +}[Command]; + +/** + * Type-safe lookup of a subcommand under a command group. + * Produces a compile-time error if `${G}.${S}` is not a registered command. + * + * @example + * SubCommand<'remove', 'agent'> // → 'remove.agent' + * SubCommand<'add', 'memory'> // → 'add.memory' + * SubCommand<'remove', 'bogus'> // → never (compile error at call site) + */ +export type SubCommand = Extract; + /** Derive command_group from command key (e.g. 'add.agent' → 'add') */ export function deriveCommandGroup(command: Command): string { const dot = command.indexOf('.'); diff --git a/src/cli/templates/__tests__/BaseRenderer.test.ts b/src/cli/templates/__tests__/BaseRenderer.test.ts index 083c20aa5..ebac2150f 100644 --- a/src/cli/templates/__tests__/BaseRenderer.test.ts +++ b/src/cli/templates/__tests__/BaseRenderer.test.ts @@ -1,22 +1,15 @@ import { BaseRenderer } from '../BaseRenderer.js'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockCopyAndRenderDir = vi.fn(); -const mockExistsSync = vi.fn(); vi.mock('../render.js', () => ({ copyAndRenderDir: (...args: unknown[]) => mockCopyAndRenderDir(...args), })); -vi.mock('node:fs', async () => { - const actual = await vi.importActual('node:fs'); - return { ...actual, existsSync: (...args: unknown[]) => mockExistsSync(...args) }; -}); - -vi.mock('../../../lib', () => ({ - APP_DIR: 'app', -})); - class TestRenderer extends BaseRenderer { constructor(config: any, sdkName: string, baseTemplateDir: string, protocol?: string) { super(config, sdkName, baseTemplateDir, protocol); @@ -28,7 +21,16 @@ class TestRenderer extends BaseRenderer { } describe('BaseRenderer', () => { - afterEach(() => vi.clearAllMocks()); + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'base-renderer-test-')); + }); + + afterEach(() => { + vi.clearAllMocks(); + rmSync(tmpDir, { recursive: true, force: true }); + }); it('getTemplateDir joins language, protocol, and sdk name', () => { const renderer = new TestRenderer( @@ -63,19 +65,18 @@ describe('BaseRenderer', () => { it('render copies base template', async () => { mockCopyAndRenderDir.mockResolvedValue(undefined); - mockExistsSync.mockReturnValue(false); const renderer = new TestRenderer( { targetLanguage: 'Python', name: 'MyAgent', hasMemory: false }, 'strands', - '/templates' + tmpDir ); await renderer.render({ outputDir: '/output' }); expect(mockCopyAndRenderDir).toHaveBeenCalledTimes(1); expect(mockCopyAndRenderDir).toHaveBeenCalledWith( - '/templates/python/http/strands/base', + join(tmpDir, 'python', 'http', 'strands', 'base'), '/output/app/MyAgent', expect.objectContaining({ projectName: 'MyAgent', Name: 'MyAgent', hasMcp: false }) ); @@ -83,19 +84,19 @@ describe('BaseRenderer', () => { it('render copies memory capability when hasMemory and dir exists', async () => { mockCopyAndRenderDir.mockResolvedValue(undefined); - mockExistsSync.mockReturnValue(true); + mkdirSync(join(tmpDir, 'typescript', 'http', 'langchain', 'capabilities', 'memory'), { recursive: true }); const renderer = new TestRenderer( { targetLanguage: 'TypeScript', name: 'Agent', hasMemory: true }, 'langchain', - '/templates' + tmpDir ); await renderer.render({ outputDir: '/out' }); expect(mockCopyAndRenderDir).toHaveBeenCalledTimes(2); expect(mockCopyAndRenderDir).toHaveBeenCalledWith( - '/templates/typescript/http/langchain/capabilities/memory', + join(tmpDir, 'typescript', 'http', 'langchain', 'capabilities', 'memory'), '/out/app/Agent/memory', expect.objectContaining({ projectName: 'Agent', hasMemory: true }) ); @@ -103,13 +104,8 @@ describe('BaseRenderer', () => { it('render skips memory capability when dir does not exist', async () => { mockCopyAndRenderDir.mockResolvedValue(undefined); - mockExistsSync.mockReturnValue(false); - const renderer = new TestRenderer( - { targetLanguage: 'Python', name: 'Agent', hasMemory: true }, - 'strands', - '/templates' - ); + const renderer = new TestRenderer({ targetLanguage: 'Python', name: 'Agent', hasMemory: true }, 'strands', tmpDir); await renderer.render({ outputDir: '/out' }); diff --git a/src/cli/tui/components/Panel.tsx b/src/cli/tui/components/Panel.tsx index de2caa8c0..77fb40ee9 100644 --- a/src/cli/tui/components/Panel.tsx +++ b/src/cli/tui/components/Panel.tsx @@ -16,7 +16,7 @@ export interface PanelProps { fullWidth?: boolean; } -export function Panel({ title, children, borderColor, height, flexGrow, flexBasis, fullWidth = false }: PanelProps) { +export function Panel({ title, children, borderColor, height, flexGrow, flexBasis, fullWidth = true }: PanelProps) { const { contentWidth } = useLayout(); return ( diff --git a/src/cli/tui/components/__tests__/Panel.test.tsx b/src/cli/tui/components/__tests__/Panel.test.tsx index 5d3a5eec4..89e9b6958 100644 --- a/src/cli/tui/components/__tests__/Panel.test.tsx +++ b/src/cli/tui/components/__tests__/Panel.test.tsx @@ -2,20 +2,12 @@ import { Panel } from '../Panel.js'; import { Text } from 'ink'; import { render } from 'ink-testing-library'; import React from 'react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -const { mockContentWidth } = vi.hoisted(() => ({ - mockContentWidth: { value: 60 }, -})); +import { describe, expect, it, vi } from 'vitest'; vi.mock('../../context/index.js', () => ({ - useLayout: () => ({ contentWidth: mockContentWidth.value }), + useLayout: () => ({ contentWidth: 80 }), })); -afterEach(() => { - mockContentWidth.value = 60; -}); - describe('Panel', () => { it('renders children content inside a border', () => { const { lastFrame } = render( @@ -41,23 +33,14 @@ describe('Panel', () => { expect(frame.indexOf('Settings')).toBeLessThan(frame.indexOf('body')); }); - it('adapts to different content widths from context', () => { - mockContentWidth.value = 30; - const { lastFrame: narrow } = render( - - test - - ); - - mockContentWidth.value = 100; - const { lastFrame: wide } = render( + it('defaults to full width', () => { + const { lastFrame } = render( test ); - - const narrowTopLine = narrow()!.split('\n')[0]!; - const wideTopLine = wide()!.split('\n')[0]!; - expect(narrowTopLine.length).toBeLessThan(wideTopLine.length); + const frame = lastFrame()!; + const topLine = frame.split('\n')[0]!; + expect(topLine.length).toBeGreaterThan(80); }); }); diff --git a/src/cli/tui/context/LayoutContext.tsx b/src/cli/tui/context/LayoutContext.tsx index e55a7366d..7acf7d006 100644 --- a/src/cli/tui/context/LayoutContext.tsx +++ b/src/cli/tui/context/LayoutContext.tsx @@ -1,16 +1,14 @@ import { useStdout } from 'ink'; import React, { type ReactNode, createContext, useContext } from 'react'; -/** Maximum content width cap */ -const MAX_CONTENT_WIDTH = 60; +const DEFAULT_WIDTH = 80; interface LayoutContextValue { - /** Global content width: min(terminalWidth, MAX_CONTENT_WIDTH) */ contentWidth: number; } const LayoutContext = createContext({ - contentWidth: MAX_CONTENT_WIDTH, + contentWidth: DEFAULT_WIDTH, }); // eslint-disable-next-line react-refresh/only-export-components @@ -18,35 +16,13 @@ export function useLayout(): LayoutContextValue { return useContext(LayoutContext); } -/** - * Build the logo dynamically based on width. - * The logo has fixed text " >_ AgentCore" on left and version on right, - * with padding in between to fill the width. - */ -// eslint-disable-next-line react-refresh/only-export-components -export function buildLogo(width: number, version?: string): string { - const left = '│ >_ AgentCore'; - const right = version ? `v${version} │` : '│'; - // -2 for the border chars already in left/right - const innerWidth = width - 2; - const paddingNeeded = innerWidth - (left.length - 1) - (right.length - 1); - const padding = ' '.repeat(Math.max(0, paddingNeeded)); - - const topBorder = '┌' + '─'.repeat(innerWidth) + '┐'; - const bottomBorder = '└' + '─'.repeat(innerWidth) + '┘'; - const middle = left + padding + right; - - return `\n${topBorder}\n${middle}\n${bottomBorder}`; -} - interface LayoutProviderProps { children: ReactNode; } export function LayoutProvider({ children }: LayoutProviderProps) { const { stdout } = useStdout(); - const terminalWidth = stdout?.columns ?? MAX_CONTENT_WIDTH; - const contentWidth = Math.min(terminalWidth, MAX_CONTENT_WIDTH); + const contentWidth = stdout?.columns ?? DEFAULT_WIDTH; return {children}; } diff --git a/src/cli/tui/context/__tests__/LayoutContext.test.ts b/src/cli/tui/context/__tests__/LayoutContext.test.ts deleted file mode 100644 index 862039b20..000000000 --- a/src/cli/tui/context/__tests__/LayoutContext.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { buildLogo } from '../LayoutContext.js'; -import { describe, expect, it } from 'vitest'; - -describe('buildLogo', () => { - it('builds logo with correct width', () => { - const logo = buildLogo(40); - - expect(logo).toContain('>_ AgentCore'); - expect(logo).toContain('┌'); - expect(logo).toContain('┐'); - expect(logo).toContain('└'); - expect(logo).toContain('┘'); - }); - - it('includes version when provided', () => { - const logo = buildLogo(50, '1.2.3'); - - expect(logo).toContain('>_ AgentCore'); - expect(logo).toContain('v1.2.3'); - }); - - it('does not include version when not provided', () => { - const logo = buildLogo(40); - - expect(logo).not.toContain('v'); - }); - - it('handles narrow width without crashing', () => { - const logo = buildLogo(20); - - expect(logo).toContain('>_ AgentCore'); - }); -}); diff --git a/src/cli/tui/context/index.ts b/src/cli/tui/context/index.ts index 05fe7f29e..60f44748a 100644 --- a/src/cli/tui/context/index.ts +++ b/src/cli/tui/context/index.ts @@ -1 +1 @@ -export { LayoutProvider, useLayout, buildLogo } from './LayoutContext'; +export { LayoutProvider, useLayout } from './LayoutContext'; diff --git a/src/cli/tui/hooks/useCreateEvaluator.ts b/src/cli/tui/hooks/useCreateEvaluator.ts index f1cad666f..087eca479 100644 --- a/src/cli/tui/hooks/useCreateEvaluator.ts +++ b/src/cli/tui/hooks/useCreateEvaluator.ts @@ -1,6 +1,6 @@ import type { EvaluatorConfig } from '../../../schema'; import { evaluatorPrimitive } from '../../primitives/registry'; -import { withAddTelemetry } from '../../telemetry/cli-command-run.js'; +import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { Level, standardize } from '../../telemetry/schemas/common-shapes.js'; import { useCallback, useEffect, useState } from 'react'; @@ -18,7 +18,7 @@ export function useCreateEvaluator() { const create = useCallback(async (config: CreateEvaluatorConfig) => { setStatus({ state: 'loading' }); try { - const addResult = await withAddTelemetry( + const addResult = await withCommandRunTelemetry( 'add.evaluator', { evaluator_type: config.config.codeBased ? 'code-based' : 'llm-as-a-judge', diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index ec91666d0..60900bc63 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -4,7 +4,7 @@ import { gatewayTargetPrimitive, policyEnginePrimitive, } from '../../primitives/registry'; -import { withAddTelemetry } from '../../telemetry/cli-command-run.js'; +import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { AuthorizerType, PolicyEngineMode, standardize } from '../../telemetry/schemas/common-shapes.js'; import type { AddGatewayConfig } from '../screens/mcp/types'; import { useCallback, useEffect, useState } from 'react'; @@ -25,7 +25,7 @@ export function useCreateGateway() { const createGateway = useCallback(async (config: AddGatewayConfig) => { setStatus({ state: 'loading' }); try { - const addResult = await withAddTelemetry( + const addResult = await withCommandRunTelemetry( 'add.gateway', { authorizer_type: standardize(AuthorizerType, config.authorizerType ?? 'NONE'), diff --git a/src/cli/tui/hooks/useCreateMemory.ts b/src/cli/tui/hooks/useCreateMemory.ts index d4196582f..f125b2615 100644 --- a/src/cli/tui/hooks/useCreateMemory.ts +++ b/src/cli/tui/hooks/useCreateMemory.ts @@ -2,7 +2,7 @@ import { ConfigIO } from '../../../lib'; import type { Memory } from '../../../schema'; import { getAvailableAgents } from '../../operations/attach'; import { memoryPrimitive } from '../../primitives/registry'; -import { withAddTelemetry } from '../../telemetry/cli-command-run.js'; +import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { useCallback, useEffect, useState } from 'react'; interface CreateMemoryConfig { @@ -26,7 +26,7 @@ export function useCreateMemory() { try { const strategiesStr = config.strategies.map(s => s.type).join(','); const strategyList = strategiesStr ? strategiesStr.split(',').map(s => s.trim().toUpperCase()) : []; - const addResult = await withAddTelemetry( + const addResult = await withCommandRunTelemetry( 'add.memory', { strategy_count: strategyList.length, diff --git a/src/cli/tui/hooks/useCreateOnlineEval.ts b/src/cli/tui/hooks/useCreateOnlineEval.ts index b853fed05..381e6d136 100644 --- a/src/cli/tui/hooks/useCreateOnlineEval.ts +++ b/src/cli/tui/hooks/useCreateOnlineEval.ts @@ -1,5 +1,5 @@ import { onlineEvalConfigPrimitive } from '../../primitives/registry'; -import { withAddTelemetry } from '../../telemetry/cli-command-run.js'; +import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { useCallback, useEffect, useState } from 'react'; interface CreateOnlineEvalConfig { @@ -20,7 +20,7 @@ export function useCreateOnlineEval() { const create = useCallback(async (config: CreateOnlineEvalConfig) => { setStatus({ state: 'loading' }); try { - const addResult = await withAddTelemetry( + const addResult = await withCommandRunTelemetry( 'add.online-eval', { evaluator_count: config.evaluators.length, diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index 7682479d3..abdeda2d9 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -19,6 +19,8 @@ import { policyPrimitive, runtimeEndpointPrimitive, } from '../../primitives/registry'; +import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; +import type { SubCommand } from '../../telemetry/schemas/command-run.js'; import { useCallback, useEffect, useRef, useState } from 'react'; // Re-export types for consumers @@ -74,7 +76,11 @@ function useRemoveResource( const remove = useCallback(async (id: TIdentifier, preview?: RemovalPreview): Promise => { setState({ isLoading: true, result: null }); - const result = await removeFnRef.current(id); + const result = await withCommandRunTelemetry, RemovalResult>( + `remove.${resourceTypeRef.current}`, + {}, + () => removeFnRef.current(id) + ); setState({ isLoading: false, result }); let logPath: string | undefined; diff --git a/src/cli/tui/screens/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index e2fabe140..aeac9e442 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -12,7 +12,7 @@ import { executeImportAgent } from '../../../operations/agent/import'; import { buildAuthorizerConfigFromJwtConfig, createManagedOAuthCredential } from '../../../primitives/auth-utils'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; import { credentialPrimitive } from '../../../primitives/registry'; -import { withAddTelemetry } from '../../../telemetry/cli-command-run.js'; +import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; import { AgentType as AgentTypeEnum, AuthorizerType as AuthorizerTypeEnum, @@ -149,7 +149,7 @@ export function useAddAgent() { const addAgent = useCallback(async (config: AddAgentConfig): Promise => { setIsLoading(true); try { - const result = await withAddTelemetry( + const result = await withCommandRunTelemetry( 'add.agent', { language: standardize(Language, config.language), diff --git a/src/cli/tui/screens/home/CommandListScreen.tsx b/src/cli/tui/screens/home/CommandListScreen.tsx index ad2f22c7c..b4c65f932 100644 --- a/src/cli/tui/screens/home/CommandListScreen.tsx +++ b/src/cli/tui/screens/home/CommandListScreen.tsx @@ -1,4 +1,3 @@ -import { buildLogo, useLayout } from '../../context'; import type { CommandMeta } from '../../utils/commands'; import { Box, Text, useApp, useStdout } from 'ink'; import React, { useEffect } from 'react'; @@ -18,11 +17,9 @@ interface CommandListScreenProps { */ export function CommandListScreen({ commands }: CommandListScreenProps) { const { exit } = useApp(); - const { contentWidth } = useLayout(); const { stdout } = useStdout(); const terminalWidth = stdout?.columns ?? 80; const maxDescWidth = Math.max(20, terminalWidth - 18); - const logo = buildLogo(contentWidth); // Exit after render useEffect(() => { @@ -34,7 +31,9 @@ export function CommandListScreen({ commands }: CommandListScreenProps) { return ( - {logo} + + {'>_ AgentCore'} + Usage: diff --git a/src/cli/tui/screens/home/HelpScreen.tsx b/src/cli/tui/screens/home/HelpScreen.tsx index 71b8d56e8..e9f3c23be 100644 --- a/src/cli/tui/screens/home/HelpScreen.tsx +++ b/src/cli/tui/screens/home/HelpScreen.tsx @@ -1,5 +1,4 @@ import { Cursor, ScreenLayout } from '../../components'; -import { useLayout } from '../../context'; import { HINTS } from '../../copy'; import { useTextInput } from '../../hooks'; import type { CommandMeta } from '../../utils/commands'; @@ -84,10 +83,8 @@ function HelpDisplay({ interactiveCount, notice, }: HelpDisplayProps) { - const { contentWidth } = useLayout(); const { stdout } = useStdout(); const terminalWidth = stdout?.columns ?? 80; - const bottomDivider = '─'.repeat(contentWidth); const allItems = [...interactiveItems, ...cliOnlyItems]; const maxLabelLen = getMaxLabelLen(allItems); @@ -131,8 +128,16 @@ function HelpDisplay({ {showCliSection && ( <> - - CLI only {'─'.repeat(Math.max(0, contentWidth - 11))} + + CLI only {cliOnlyItems.map((item, idx) => ( {notice}} - - {bottomDivider} + {hintText} diff --git a/src/cli/tui/screens/home/HomeScreen.tsx b/src/cli/tui/screens/home/HomeScreen.tsx index c9d1d521c..7c67222fa 100644 --- a/src/cli/tui/screens/home/HomeScreen.tsx +++ b/src/cli/tui/screens/home/HomeScreen.tsx @@ -1,6 +1,5 @@ import { findConfigRoot } from '../../../../lib'; import { Cursor, ScreenLayout } from '../../components'; -import { buildLogo, useLayout } from '../../context'; import { HINTS } from '../../copy'; import { Box, Text, useApp, useInput } from 'ink'; import React from 'react'; @@ -53,10 +52,7 @@ interface HomeScreenProps { export function HomeScreen({ cwd: _cwd, version, onShowHelp, onSelectCreate }: HomeScreenProps) { const { exit } = useApp(); - const { contentWidth } = useLayout(); const showQuickStart = !hasProject(); - const logo = buildLogo(contentWidth, version); - const divider = '─'.repeat(contentWidth); useInput((input, key) => { if (key.escape) { @@ -82,8 +78,11 @@ export function HomeScreen({ cwd: _cwd, version, onShowHelp, onSelectCreate }: H return ( - {/* Logo with version - always at top */} - {logo} + {/* Logo with version */} + + {'>_ AgentCore'} + {version && v{version}} + {/* Input - directly under logo */} @@ -97,8 +96,15 @@ export function HomeScreen({ cwd: _cwd, version, onShowHelp, onSelectCreate }: H {showQuickStart ? : } {/* Divider and hint at bottom */} - - {divider} + {HINTS.HOME} diff --git a/src/cli/tui/screens/identity/useCreateIdentity.ts b/src/cli/tui/screens/identity/useCreateIdentity.ts index e214d1e67..c9293665b 100644 --- a/src/cli/tui/screens/identity/useCreateIdentity.ts +++ b/src/cli/tui/screens/identity/useCreateIdentity.ts @@ -2,7 +2,7 @@ import { ConfigIO } from '../../../../lib'; import type { Credential } from '../../../../schema'; import type { AddCredentialOptions } from '../../../primitives/CredentialPrimitive'; import { credentialPrimitive } from '../../../primitives/registry'; -import { withAddTelemetry } from '../../../telemetry/cli-command-run.js'; +import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; import { useCallback, useEffect, useState } from 'react'; interface CreateStatus { @@ -17,7 +17,7 @@ export function useCreateIdentity() { const create = useCallback(async (config: AddCredentialOptions) => { setStatus({ state: 'loading' }); try { - const result = await withAddTelemetry( + const result = await withCommandRunTelemetry( 'add.credential', { credential_type: config.authorizerType === 'OAuthCredentialProvider' ? 'oauth' : 'api-key', diff --git a/src/cli/tui/screens/policy/AddPolicyFlow.tsx b/src/cli/tui/screens/policy/AddPolicyFlow.tsx index 9b3542cb8..8238d84c8 100644 --- a/src/cli/tui/screens/policy/AddPolicyFlow.tsx +++ b/src/cli/tui/screens/policy/AddPolicyFlow.tsx @@ -1,5 +1,5 @@ import { policyEnginePrimitive, policyPrimitive } from '../../../primitives/registry'; -import { withAddTelemetry } from '../../../telemetry/cli-command-run.js'; +import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; import { AttachMode, ValidationMode, standardize } from '../../../telemetry/schemas/common-shapes.js'; import { ErrorPrompt, @@ -130,7 +130,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD }, []); const commitEngine = useCallback(async (engineName: string, gateways?: string[], mode?: 'LOG_ONLY' | 'ENFORCE') => { - const result = await withAddTelemetry( + const result = await withCommandRunTelemetry( 'add.policy-engine', { attach_gateway_count: gateways?.length ?? 0, @@ -164,7 +164,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD ); const handlePolicyComplete = useCallback(async (config: AddPolicyConfig) => { - const result = await withAddTelemetry( + const result = await withCommandRunTelemetry( 'add.policy', { source_type: config.sourceFile ? 'file' : config.sourceMethod === 'generate' ? 'generate' : 'statement', diff --git a/src/cli/tui/screens/remove/useRemoveFlow.ts b/src/cli/tui/screens/remove/useRemoveFlow.ts index 8c4d3ef69..96ddd2f64 100644 --- a/src/cli/tui/screens/remove/useRemoveFlow.ts +++ b/src/cli/tui/screens/remove/useRemoveFlow.ts @@ -1,7 +1,9 @@ import { ConfigIO, getWorkingDirectory } from '../../../../lib'; import { findStack } from '../../../cloudformation/stack-discovery'; import { getErrorMessage } from '../../../errors'; +import type { RemovalResult } from '../../../operations/remove/types'; import { createDefaultProjectSpec } from '../../../project'; +import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; import { type Step, areStepsComplete, hasStepError } from '../../components'; import { withMinDuration } from '../../utils'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -146,16 +148,23 @@ export function useRemoveFlow({ force, dryRun }: RemoveFlowOptions): RemoveFlowS // Reset all schemas to default empty state updateStep(0, { status: 'running' }); try { - await withMinDuration(async () => { - const configIO = new ConfigIO(); - - // Reset agentcore.json (keep project name) - const defaultProjectSpec = createDefaultProjectSpec(projectName || 'Project'); - await configIO.writeProjectSpec(defaultProjectSpec); - - // Preserve aws-targets.json and deployed-state.json so that - // a subsequent `agentcore deploy` can tear down existing stacks. - }); + const res = await withCommandRunTelemetry( + 'remove.all', + {}, + (): Promise => + withMinDuration(async () => { + const configIO = new ConfigIO(); + + // Reset agentcore.json (keep project name) + const defaultProjectSpec = createDefaultProjectSpec(projectName || 'Project'); + await configIO.writeProjectSpec(defaultProjectSpec); + + // Preserve aws-targets.json and deployed-state.json so that + // a subsequent `agentcore deploy` can tear down existing stacks. + return { success: true }; + }) + ); + if (!res.success) throw new Error(res.error); updateStep(0, { status: 'success' }); } catch (err) { updateStep(0, { status: 'error', error: getErrorMessage(err) }); diff --git a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx index 83bda78e5..77c07b2e8 100644 --- a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx +++ b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx @@ -1,6 +1,6 @@ import { ConfigIO } from '../../../../lib'; import { runtimeEndpointPrimitive } from '../../../primitives/registry'; -import { withAddTelemetry } from '../../../telemetry/cli-command-run.js'; +import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; import { ErrorPrompt } from '../../components'; import { AddSuccessScreen } from '../add/AddSuccessScreen'; import { AddRuntimeEndpointScreen } from './AddRuntimeEndpointScreen'; @@ -79,7 +79,7 @@ export function AddRuntimeEndpointFlow({ }, [isInteractive, flow.name, onExit]); const handleCreateComplete = useCallback((config: RuntimeEndpointWizardConfig) => { - void withAddTelemetry('add.runtime-endpoint', {}, () => + void withCommandRunTelemetry('add.runtime-endpoint', {}, () => runtimeEndpointPrimitive.add({ runtime: config.runtimeName, endpoint: config.endpointName, diff --git a/src/cli/update-notifier.ts b/src/cli/update-notifier.ts index 98c468296..dca990c15 100644 --- a/src/cli/update-notifier.ts +++ b/src/cli/update-notifier.ts @@ -1,12 +1,15 @@ +import { ONE_DAY_MS } from '../lib/time-constants.js'; import { compareVersions, fetchLatestVersion } from './commands/update/action.js'; import { PACKAGE_VERSION } from './constants.js'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { homedir } from 'os'; import { join } from 'path'; -const CACHE_DIR = join(homedir(), '.agentcore'); -const CACHE_FILE = join(CACHE_DIR, 'update-check.json'); -const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // every 24 hours +function getConfigDir(): string { + return process.env.AGENTCORE_CONFIG_DIR ?? join(homedir(), '.agentcore'); +} + +const CHECK_INTERVAL_MS = ONE_DAY_MS; interface CacheData { lastCheck: number; @@ -20,7 +23,7 @@ export interface UpdateCheckResult { async function readCache(): Promise { try { - const data = await readFile(CACHE_FILE, 'utf-8'); + const data = await readFile(join(getConfigDir(), 'update-check.json'), 'utf-8'); return JSON.parse(data) as CacheData; } catch { return null; @@ -29,8 +32,9 @@ async function readCache(): Promise { async function writeCache(data: CacheData): Promise { try { - await mkdir(CACHE_DIR, { recursive: true }); - await writeFile(CACHE_FILE, JSON.stringify(data), 'utf-8'); + const dir = getConfigDir(); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'update-check.json'), JSON.stringify(data), 'utf-8'); } catch { // Silently ignore cache write failures } diff --git a/src/lib/index.ts b/src/lib/index.ts index cab20d720..d21baec86 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -27,3 +27,4 @@ export * from './utils'; // Schema I/O utilities export * from './schemas/io'; +export * from './time-constants'; diff --git a/src/lib/time-constants.ts b/src/lib/time-constants.ts new file mode 100644 index 000000000..679ddc2b2 --- /dev/null +++ b/src/lib/time-constants.ts @@ -0,0 +1,4 @@ +export const ONE_SECOND_MS = 1000; +export const ONE_MINUTE_MS = ONE_SECOND_MS * 60; +export const ONE_HOUR_MS = ONE_MINUTE_MS * 60; +export const ONE_DAY_MS = ONE_HOUR_MS * 24; diff --git a/src/lib/utils/__tests__/polling.test.ts b/src/lib/utils/__tests__/polling.test.ts new file mode 100644 index 000000000..c3440b669 --- /dev/null +++ b/src/lib/utils/__tests__/polling.test.ts @@ -0,0 +1,217 @@ +import { PollExhaustedError, PollTimeoutError, isThrottlingError, poll } from '../polling.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/* eslint-disable @typescript-eslint/require-await */ + +describe('poll', () => { + it('returns immediately on first success', async () => { + const result = await poll({ fn: async () => ({ done: true, value: 42 }), maxAttempts: 5 }); + expect(result).toBe(42); + }); + + it('polls until success', async () => { + let count = 0; + const result = await poll({ + fn: async () => { + count++; + return count === 3 ? { done: true, value: 'ok' } : { done: false }; + }, + maxAttempts: 5, + delayMs: 1, + }); + expect(result).toBe('ok'); + expect(count).toBe(3); + }); + + it('throws PollExhaustedError when maxAttempts exceeded', async () => { + await expect(poll({ fn: async () => ({ done: false }), maxAttempts: 3, delayMs: 1 })).rejects.toThrow( + PollExhaustedError + ); + }); + + it('throws PollTimeoutError when timeout exceeded', async () => { + await expect(poll({ fn: async () => ({ done: false }), timeoutMs: 50, delayMs: 20 })).rejects.toThrow( + PollTimeoutError + ); + }); + + describe('backoff', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('applies exponential backoff', async () => { + let count = 0; + const promise = poll({ + fn: async () => { + count++; + return count === 4 ? { done: true, value: 'done' } : { done: false }; + }, + maxAttempts: 5, + delayMs: 100, + backoffFactor: 2, + }); + await vi.advanceTimersByTimeAsync(100); // 1st delay: 100 + await vi.advanceTimersByTimeAsync(200); // 2nd delay: 200 + await vi.advanceTimersByTimeAsync(400); // 3rd delay: 400 + const result = await promise; + expect(result).toBe('done'); + }); + + it('caps delay at maxDelayMs', async () => { + let count = 0; + const promise = poll({ + fn: async () => { + count++; + return count === 4 ? { done: true, value: 'done' } : { done: false }; + }, + maxAttempts: 5, + delayMs: 100, + backoffFactor: 10, + maxDelayMs: 500, + }); + await vi.advanceTimersByTimeAsync(100); // 1st: 100 + await vi.advanceTimersByTimeAsync(500); // 2nd: capped at 500 + await vi.advanceTimersByTimeAsync(500); // 3rd: capped at 500 + const result = await promise; + expect(result).toBe('done'); + }); + }); + + it('retries on error by default', async () => { + let count = 0; + const result = await poll({ + fn: async () => { + count++; + if (count < 3) throw new Error('transient'); + return { done: true, value: 'ok' }; + }, + maxAttempts: 5, + delayMs: 1, + }); + expect(result).toBe('ok'); + expect(count).toBe(3); + }); + + it('aborts on error when onError returns abort', async () => { + const err = new Error('fatal'); + await expect( + poll({ + fn: async () => { + throw err; + }, + maxAttempts: 5, + delayMs: 1, + onError: () => 'abort', + }) + ).rejects.toThrow('fatal'); + }); + + it('throws PollExhaustedError after maxConsecutiveErrors', async () => { + await expect( + poll({ + fn: async () => { + throw new Error('fail'); + }, + maxAttempts: 10, + delayMs: 1, + maxConsecutiveErrors: 3, + }) + ).rejects.toThrow(PollExhaustedError); + }); + + it('PollExhaustedError includes cause with the last error', async () => { + const err = await poll({ + fn: async () => { + throw new Error('Rate exceeded'); + }, + maxAttempts: 3, + delayMs: 1, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(PollExhaustedError); + expect((err as PollExhaustedError).cause).toBeInstanceOf(Error); + expect(((err as PollExhaustedError).cause as Error).message).toBe('Rate exceeded'); + }); + + it('PollTimeoutError includes cause with the last error', async () => { + const err = await poll({ + fn: async () => { + throw new Error('service unavailable'); + }, + timeoutMs: 50, + delayMs: 10, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(PollTimeoutError); + expect((err as PollTimeoutError).cause).toBeInstanceOf(Error); + expect(((err as PollTimeoutError).cause as Error).message).toBe('service unavailable'); + }); + + it('cause is undefined when no errors occurred during polling', async () => { + const err = await poll({ + fn: async () => ({ done: false }), + maxAttempts: 2, + delayMs: 1, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(PollExhaustedError); + expect((err as PollExhaustedError).cause).toBeUndefined(); + }); + + it('resets consecutive error count on success', async () => { + let count = 0; + const result = await poll({ + fn: async () => { + count++; + if (count === 1) throw new Error('err1'); + if (count === 2) throw new Error('err2'); + if (count === 3) return { done: false }; // success resets counter + if (count === 4) throw new Error('err3'); + if (count === 5) throw new Error('err4'); + return { done: true, value: 'ok' }; + }, + maxAttempts: 10, + delayMs: 1, + maxConsecutiveErrors: 3, + }); + expect(result).toBe('ok'); + }); + + it('throws if neither maxAttempts nor timeoutMs provided', async () => { + await expect(poll({ fn: async () => ({ done: true, value: 1 }) })).rejects.toThrow( + 'poll() requires at least one of maxAttempts or timeoutMs' + ); + }); + + it('supports both maxAttempts and timeoutMs together', async () => { + // maxAttempts hit first + await expect( + poll({ fn: async () => ({ done: false }), maxAttempts: 2, timeoutMs: 10000, delayMs: 1 }) + ).rejects.toThrow(PollExhaustedError); + }); +}); + +describe('isThrottlingError', () => { + it('detects ThrottlingException by name', () => { + expect(isThrottlingError({ name: 'ThrottlingException', message: '' })).toBe(true); + }); + + it('detects Rate exceeded in message', () => { + expect(isThrottlingError(new Error('Rate exceeded'))).toBe(true); + }); + + it('detects TooManyRequestsException', () => { + expect(isThrottlingError({ name: 'TooManyRequestsException', message: '' })).toBe(true); + }); + + it('returns false for non-throttle errors', () => { + expect(isThrottlingError(new Error('Stack not found'))).toBe(false); + }); + + it('returns false for null/undefined', () => { + expect(isThrottlingError(null)).toBe(false); + expect(isThrottlingError(undefined)).toBe(false); + }); +}); diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index d595d9bf7..8902fca44 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -11,4 +11,5 @@ export { } from './subprocess'; export { parseTimeString } from './time-parser'; export { parseJsonRpcResponse } from './json-rpc'; +export { poll, isThrottlingError, PollTimeoutError, PollExhaustedError } from './polling'; export { validateAgentSchema, validateProjectSchema } from './zod'; diff --git a/src/lib/utils/polling.ts b/src/lib/utils/polling.ts new file mode 100644 index 000000000..3486e634f --- /dev/null +++ b/src/lib/utils/polling.ts @@ -0,0 +1,109 @@ +/** + * Shared polling/retry utility for async operations. + */ + +export type PollResult = { done: true; value: T } | { done: false }; + +export interface PollOptions { + /** Async function called each iteration. Return {done: true, value} when complete, {done: false} to keep polling. */ + fn: () => Promise>; + /** Max number of attempts before throwing PollExhaustedError. */ + maxAttempts?: number; + /** Max total time in ms before throwing PollTimeoutError. */ + timeoutMs?: number; + /** Delay between iterations in ms. Default 5000. */ + delayMs?: number; + /** Multiply delay by this factor each iteration. Default 1 (fixed). */ + backoffFactor?: number; + /** Cap on delay in ms. */ + maxDelayMs?: number; + /** Abort after this many consecutive errors. */ + maxConsecutiveErrors?: number; + /** Called when fn throws. Return 'retry' to continue or 'abort' to rethrow. Default: 'retry'. */ + onError?: (err: unknown) => 'retry' | 'abort'; +} + +export class PollTimeoutError extends Error { + constructor(timeoutMs: number, options?: { cause?: unknown }) { + super(`Polling timed out after ${timeoutMs}ms`, options); + this.name = 'PollTimeoutError'; + } +} + +export class PollExhaustedError extends Error { + constructor(maxAttempts: number, options?: { cause?: unknown }) { + super(`Polling exhausted after ${maxAttempts} attempts`, options); + this.name = 'PollExhaustedError'; + } +} + +export async function poll(options: PollOptions): Promise { + const { + fn, + maxAttempts, + timeoutMs, + delayMs = 5000, + backoffFactor = 1, + maxDelayMs, + maxConsecutiveErrors, + onError, + } = options; + + if (maxAttempts === undefined && timeoutMs === undefined) { + throw new Error('poll() requires at least one of maxAttempts or timeoutMs'); + } + + const start = Date.now(); + let attempts = 0; + let consecutiveErrors = 0; + let currentDelay = delayMs; + let lastError: unknown = undefined; + + while (true) { + if (maxAttempts !== undefined && attempts >= maxAttempts) { + throw new PollExhaustedError(maxAttempts, { cause: lastError }); + } + if (timeoutMs !== undefined && Date.now() - start >= timeoutMs) { + throw new PollTimeoutError(timeoutMs, { cause: lastError }); + } + + attempts++; + + try { + const result = await fn(); + consecutiveErrors = 0; + if (result.done) return result.value; + } catch (err: unknown) { + const action = onError ? onError(err) : 'retry'; + if (action === 'abort') throw err; + lastError = err; + consecutiveErrors++; + if (maxConsecutiveErrors && consecutiveErrors >= maxConsecutiveErrors) { + throw new PollExhaustedError(attempts, { cause: lastError }); + } + } + + // Don't sleep if we're about to exceed timeout + if (timeoutMs !== undefined && Date.now() - start + currentDelay >= timeoutMs) { + throw new PollTimeoutError(timeoutMs, { cause: lastError }); + } + + await new Promise(resolve => setTimeout(resolve, currentDelay)); + currentDelay = maxDelayMs ? Math.min(currentDelay * backoffFactor, maxDelayMs) : currentDelay * backoffFactor; + } +} + +/** Check if an error is an AWS throttling/rate-limit error. */ +export function isThrottlingError(err: unknown): boolean { + if (err == null || typeof err !== 'object') return false; + const name = (err as { name?: string }).name ?? ''; + const message = (err as { message?: string }).message ?? ''; + return ( + name === 'ThrottlingException' || + name === 'Throttling' || + name === 'TooManyRequestsException' || + name === 'RequestLimitExceeded' || + message.includes('Rate exceeded') || + message.includes('Throttling') + ); +} From 617534b05e52ebdee447c96cdbd9774a9b7b832e Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Mon, 11 May 2026 16:13:32 -0400 Subject: [PATCH 12/27] chore: replace PAT token with GitHub App token (#179) Replace secrets.PAT_TOKEN with a short-lived token generated by the agentcore-devx-automation GitHub App (ID: 3637953) via actions/create-github-app-token@v1. This improves security by using ephemeral tokens scoped to the installation rather than long-lived personal access tokens. Requires adding repo variable APP_ID=3637953 and repo secret APP_PRIVATE_KEY with the app's RSA private key. --- .github/workflows/agent-restricted.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/agent-restricted.yml b/.github/workflows/agent-restricted.yml index 7a0948861..d229919a4 100644 --- a/.github/workflows/agent-restricted.yml +++ b/.github/workflows/agent-restricted.yml @@ -66,6 +66,13 @@ jobs: - uses: actions/checkout@v6 + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Run Strands Agent uses: ./.github/actions/strands-action with: @@ -78,6 +85,6 @@ jobs: agent_runner: ${{ inputs.agent_runner }} aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} aws_region: 'us-west-2' - pat_token: ${{ secrets.PAT_TOKEN }} + pat_token: ${{ steps.app-token.outputs.token }} env: STRANDS_TOOLS_DIRECTORY: 'true' From 8435a4361fb8240e2c5e50676f06e997ca516d1b Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Mon, 11 May 2026 16:26:40 -0400 Subject: [PATCH 13/27] chore: replace CDK_REPO_TOKEN PAT with GitHub App token in e2e workflows (#181) Use actions/create-github-app-token@v1 to generate a short-lived token for cloning the CDK repo instead of the CDK_REPO_TOKEN PAT secret. --- .github/workflows/e2e-tests-full.yml | 9 ++++++++- .github/workflows/e2e-tests.yml | 10 +++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-tests-full.yml b/.github/workflows/e2e-tests-full.yml index b34410e8f..022e15ca8 100644 --- a/.github/workflows/e2e-tests-full.yml +++ b/.github/workflows/e2e-tests-full.yml @@ -57,6 +57,13 @@ jobs: parse-json-secrets: true - run: npm ci - run: npm run build + - name: Generate GitHub App Token + if: matrix.cdk-source == 'main' + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Build CDK package from main if: matrix.cdk-source == 'main' run: | @@ -67,7 +74,7 @@ jobs: TARBALL=$(npm pack --pack-destination "$RUNNER_TEMP" | tail -1) echo "CDK_TARBALL=$RUNNER_TEMP/$TARBALL" >> "$GITHUB_ENV" env: - CDK_REPO_TOKEN: ${{ secrets.CDK_REPO_TOKEN }} + CDK_REPO_TOKEN: ${{ steps.app-token.outputs.token }} CDK_REPO: ${{ secrets.CDK_REPO_NAME }} - name: Install CLI globally run: npm install -g "$(npm pack | tail -1)" diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index e5f4aa9e2..64bea266a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -76,8 +76,12 @@ jobs: E2E,${{ secrets.E2E_SECRET_ARN }} parse-json-secrets: true - # Build @aws/agentcore-cdk from source for cross-package testing. - # Requires secrets: CDK_REPO_NAME (org/repo), CDK_REPO_TOKEN (fine-grained PAT) + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Build CDK package from main run: | git clone --depth 1 "https://x-access-token:${CDK_REPO_TOKEN}@github.com/${CDK_REPO}.git" /tmp/cdk-repo @@ -87,7 +91,7 @@ jobs: TARBALL=$(npm pack --pack-destination "$RUNNER_TEMP" | tail -1) echo "CDK_TARBALL=$RUNNER_TEMP/$TARBALL" >> "$GITHUB_ENV" env: - CDK_REPO_TOKEN: ${{ secrets.CDK_REPO_TOKEN }} + CDK_REPO_TOKEN: ${{ steps.app-token.outputs.token }} CDK_REPO: ${{ secrets.CDK_REPO_NAME }} - run: npm ci From fec7c7e4bd8b65f3fededb4f55b98790c94497b8 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Mon, 11 May 2026 17:17:25 -0400 Subject: [PATCH 14/27] fix: add owner param to GitHub App token for cross-repo access (#182) The app token needs `owner: aws` to generate a token scoped to all repos the app is installed on, not just the current repo. Without this, the token can't clone the CDK repo. --- .github/workflows/e2e-tests-full.yml | 1 + .github/workflows/e2e-tests.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/e2e-tests-full.yml b/.github/workflows/e2e-tests-full.yml index 022e15ca8..46bbf0d52 100644 --- a/.github/workflows/e2e-tests-full.yml +++ b/.github/workflows/e2e-tests-full.yml @@ -64,6 +64,7 @@ jobs: with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: aws - name: Build CDK package from main if: matrix.cdk-source == 'main' run: | diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 64bea266a..70e9e54b3 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -82,6 +82,7 @@ jobs: with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: aws - name: Build CDK package from main run: | git clone --depth 1 "https://x-access-token:${CDK_REPO_TOKEN}@github.com/${CDK_REPO}.git" /tmp/cdk-repo From bb2819bcb7bc3b58103616ca7e30f301efed75a2 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Wed, 13 May 2026 12:12:22 -0400 Subject: [PATCH 15/27] chore: sync main with public/main (2026-05-13) (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add GitHub Action for automated PR review via AgentCore Harness (#934) * feat: add GitHub Action for automated PR review via AgentCore Harness Adds a workflow that reviews PRs using Bedrock AgentCore Harness. The harness runs an AI agent in an isolated microVM with gh, git, and pre-cloned repos that fetches PR diffs and posts review comments. Workflow: - Triggers on PR open/reopen for agentcore-cli-devs team members - Supports manual workflow_dispatch for any PR URL - Adds/removes ai-reviewing label during review - Authenticates via GitHub OIDC to assume AWS role Files: - .github/workflows/pr-ai-review.yml — main workflow - .github/scripts/python/harness_review.py — harness invocation script - .github/scripts/python/harness_config.py — config from env vars - .github/scripts/models/ — local boto3 service model (InvokeHarness not yet in standard boto3) Required secrets: - HARNESS_AWS_ROLE_ARN — IAM role ARN for OIDC - HARNESS_ACCOUNT_ID — AWS account ID - HARNESS_ID — Harness ID * refactor: replace local service model with raw HTTP + SigV4 signing Eliminates the 220KB bundled service model by using direct HTTP requests with SigV4 authentication to invoke the harness endpoint. No extra dependencies needed — urllib3, SigV4Auth, and EventStreamBuffer are all part of botocore/boto3. Rejected: invoke_agent_runtime API | server rejects harness ARNs with ResourceNotFoundException Confidence: high Scope-risk: moderate * refactor: inline harness config into review script Remove separate harness_config.py — env vars are read directly in harness_review.py. One less file to maintain, config is still driven entirely by environment variables set in the GitHub workflow. * refactor: extract invoke_harness helper for cleaner main flow * refactor: simplify config and improve script readability - Replace HARNESS_ACCOUNT_ID + HARNESS_ID with single HARNESS_ARN env var - Extract prompts into separate .md files in .github/scripts/prompts/ - Extract stream parsing into print_stream() function - Add close_group() helper to deduplicate ::group:: bookkeeping * refactor: separate event parsing from display logic Extract parse_events() generator to handle binary stream decoding, keeping print_stream() focused on formatting and log groups. * docs: add explanatory comments to harness review functions * refactor: derive region from HARNESS_ARN instead of separate env var Eliminates HARNESS_REGION env var — the region is extracted from the ARN directly, so there's no risk of a mismatch causing confusing SigV4 auth errors. * chore: rename label to agentcore-harness-reviewing * refactor: move auth check to job level so entire review is skipped early Split into authorize + ai-review jobs. The ai-review job only runs if the PR author is authorized (team member or write access) or if triggered via workflow_dispatch. Removes repeated if conditions from every step. * chore: exclude AI prompt templates from prettier Prompt markdown files use intentional formatting that prettier would reflow, breaking the prompt structure. * fix: buffer streaming text to avoid per-token log lines in GitHub Actions (#946) Each text delta from the harness was printed individually with flush, creating a separate log line per token. Now text is buffered and flushed as complete lines at block boundaries. * fix: allow code-based evaluators in online eval configs (#947) * fix: allow code-based evaluators in online eval configs Remove restrictions that blocked code-based evaluators from being used in online evaluation configs. The service now supports code-based evaluators for online evaluation. Changes: - Remove code-based evaluator block in OnlineEvalConfigPrimitive - Remove code-based evaluator validation in schema superRefine - Remove code-based evaluator filter in TUI evaluator picker * style: fix prettier formatting * fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs (#949) * fix: add TTY detection before TUI fallbacks to prevent agent/CI hangs When commands are invoked without flags in non-interactive environments (CI, piped stdin, agent automation), the CLI falls through to Ink TUI rendering which hangs indefinitely. Add a requireTTY() guard at every TUI entry point that checks process.stdout.isTTY and exits with a helpful error message directing users to --help for non-interactive flags. Closes #685 * fix: check both stdin and stdout isTTY in requireTTY guard The hang from #685 is caused by stdin not being a TTY (Ink reads keyboard input from stdin), not stdout. Check both stdin and stdout so the guard fires for piped stdin, redirected stdout, and CI environments where both are non-TTY. * fix: agentcore dev not working in windows (#951) * fix: use pull_request_target for fork PR support (#958) * fix: make label step non-blocking for fork PRs Fork PRs get read-only GITHUB_TOKEN regardless of workflow permissions, causing the addLabels API call to fail with 403. This crashed the entire job before the review could run. continue-on-error lets the review proceed even when labeling fails. * fix: use pull_request_target for full write access on fork PRs pull_request gives a read-only GITHUB_TOKEN for fork PRs, preventing labels and secrets from working. pull_request_target runs in the base repo context with full permissions. This is safe because we never check out or execute fork code — the harness fetches the PR diff via the GitHub API. * fix: lower eventExpiryDuration minimum from 7 to 3 days (closes #744) (#956) The AWS CreateMemory API allows a minimum of 3 days, but the CLI schema was rejecting values below 7. Update the Zod schema, LLM compacted types, import clamping logic, and all related tests. * fix: display session ID after CLI invoke completes (#957) * fix: display session ID after CLI invoke completes (closes #664) The streaming and non-streaming invoke responses include a session ID from the runtime, but the CLI paths discarded it. Now prints the session ID and a resume command hint after invoke output. * fix: include sessionId in AGUI protocol invoke result * test: add browser tests for agent inspector (#938) * feat: add telemetry schemas and client (#941) * chore: bump version to 0.11.0 (#967) Co-authored-by: github-actions[bot] * fix(invoke): auto-generate session ID for bearer-token invocations (#953) Closes #840 When invoking an agent with a bearer token (OAuth/CUSTOM_JWT) and no session ID, `AgentCoreMemoryConfig` raised a Pydantic validation error because `session_id=None` is rejected. Unlike SigV4 callers, bearer-token callers do not get a server-side auto-generated runtime session ID. Two-layer fix: 1. CLI synthesizes a UUID in `invoke` action when `--bearer-token` is set and `--session-id` is missing, using the existing `generateSessionId` helper. Covers both explicit `--bearer-token` and the CUSTOM_JWT auto-fetch path. 2. Strands memory session templates (http, agui, a2a) synthesize a UUID when `session_id` is falsy before constructing AgentCoreMemoryConfig. Protects direct runtime callers (curl, custom apps) who forget the `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` header. Snapshot tests updated. * fix: show 'Computing diff changes...' step during deploy diff phase (#952) The deploy TUI appeared frozen for 5-15 seconds between preflight completion and 'Publish assets' while cdkToolkitWrapper.diff() ran silently with no step marked as running. Add a dedicated pre-deploy diff step that transitions running -> success around the diff call so StepProgress always has something to highlight. Closes #781 * test: split browser tests into its own job, fix logs path (#975) * feat(invoke): add --prompt-file and stdin support for long prompts (#974) * feat(invoke): add --prompt-file and stdin support for long prompts Long prompts hit shell argument limits (E2BIG, typically 128KB-2MB) when passed as positional args. This adds two new sources: - --prompt-file : read prompt from a file - piped stdin: when no prompt is given and stdin is not a TTY, read the prompt from stdin Precedence is hybrid and backward-compatible: --prompt > positional > --prompt-file > stdin --prompt-file combined with piped stdin content returns an explicit collision error rather than silently picking one. Closes #686 * docs(invoke): document --prompt-file and stdin support * fix(import): remove experimental warning from import command (#977) The import feature has stabilized and no longer needs the experimental label. * fix: duplicate header flash and help menu truncation (closes #895, closes #637) (#955) - Return null during brief transitional phases to prevent Ink from rendering a header that gets immediately replaced by a different frame - Consolidate CreateScreen phases into a single Screen mount - Make help menu description width responsive to terminal size - Remove hardcoded 50-char description truncation limit * test: configure git in browser tests workflow (#976) * feat: add project-name option to create (#969) * Add project-name option to create * fix: address review feedback — restore name description and move backfill logic * ci: bump the github-actions group across 1 directory with 4 updates (#964) Bumps the github-actions group with 4 updates in the / directory: [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials), [actions/github-script](https://github.com/actions/github-script), [softprops/action-gh-release](https://github.com/softprops/action-gh-release) and [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action). Updates `aws-actions/configure-aws-credentials` from 5 to 6 - [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/v5...v6) Updates `actions/github-script` from 8 to 9 - [Commits](https://github.com/actions/github-script/compare/v8...v9) Updates `softprops/action-gh-release` from 2 to 3 - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) Updates `slackapi/slack-github-action` from 3.0.1 to 3.0.2 - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Changelog](https://github.com/slackapi/slack-github-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/slackapi/slack-github-action/compare/v3.0.1...v3.0.2) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: aws-actions/configure-aws-credentials dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: slackapi/slack-github-action dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump aws-cdk-lib (#962) Bumps the aws-cdk group with 1 update in the / directory: [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib). Updates `aws-cdk-lib` from 2.248.0 to 2.250.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.250.0/packages/aws-cdk-lib) --- updated-dependencies: - dependency-name: aws-cdk-lib dependency-version: 2.250.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump postcss from 8.5.8 to 8.5.10 (#961) Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.10. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.10) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.10 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump secretlint from 11.4.1 to 12.2.0 (#916) Bumps [secretlint](https://github.com/secretlint/secretlint) from 11.4.1 to 12.2.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v11.4.1...v12.2.0) --- updated-dependencies: - dependency-name: secretlint dependency-version: 12.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @vitest/coverage-v8 from 4.1.2 to 4.1.5 (#915) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.2 to 4.1.5. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @secretlint/secretlint-rule-preset-recommend (#914) Bumps [@secretlint/secretlint-rule-preset-recommend](https://github.com/secretlint/secretlint) from 11.4.1 to 12.2.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v11.4.1...v12.2.0) --- updated-dependencies: - dependency-name: "@secretlint/secretlint-rule-preset-recommend" dependency-version: 12.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the aws-sdk group across 1 directory with 14 updates (#912) Bumps the aws-sdk group with 14 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@aws-sdk/client-application-signals](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-application-signals) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agentcore](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-agentcore-control](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore-control) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cloudformation](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudformation) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cloudwatch-logs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudwatch-logs) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-resource-groups-tagging-api](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-resource-groups-tagging-api) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-xray](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-xray) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/credential-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/credential-providers) | `3.1036.0` | `3.1037.0` | | [@aws-sdk/client-cognito-identity-provider](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cognito-identity-provider) | `3.1036.0` | `3.1037.0` | Updates `@aws-sdk/client-application-signals` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-application-signals/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-application-signals) Updates `@aws-sdk/client-bedrock` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock) Updates `@aws-sdk/client-bedrock-agent` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agent) Updates `@aws-sdk/client-bedrock-agentcore` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agentcore) Updates `@aws-sdk/client-bedrock-agentcore-control` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore-control/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-agentcore-control) Updates `@aws-sdk/client-bedrock-runtime` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-bedrock-runtime) Updates `@aws-sdk/client-cloudformation` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudformation/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cloudformation) Updates `@aws-sdk/client-cloudwatch-logs` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudwatch-logs/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cloudwatch-logs) Updates `@aws-sdk/client-resource-groups-tagging-api` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-resource-groups-tagging-api/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-resource-groups-tagging-api) Updates `@aws-sdk/client-s3` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-s3) Updates `@aws-sdk/client-sts` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-sts) Updates `@aws-sdk/client-xray` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-xray/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-xray) Updates `@aws-sdk/credential-providers` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/credential-providers/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/packages/credential-providers) Updates `@aws-sdk/client-cognito-identity-provider` from 3.1036.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cognito-identity-provider/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-cognito-identity-provider) --- updated-dependencies: - dependency-name: "@aws-sdk/client-application-signals" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agent" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore-control" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-runtime" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudformation" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudwatch-logs" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cognito-identity-provider" dependency-version: 3.1034.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-resource-groups-tagging-api" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-xray" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/credential-providers" dependency-version: 3.1034.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump hono from 4.12.12 to 4.12.14 (#868) Bumps [hono](https://github.com/honojs/hono) from 4.12.12 to 4.12.14. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.12...v4.12.14) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.14 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump esbuild from 0.27.4 to 0.28.0 (#862) Bumps [esbuild](https://github.com/evanw/esbuild) from 0.27.4 to 0.28.0. - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.27.4...v0.28.0) --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.28.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * test: speed up CI and fix mock cleanup gaps (#989) * test: speed up CI and fix mock cleanup gaps - Node 20 only on PRs (full matrix on main) - 3-way vitest sharding for unit tests with blob report merging - Pre-bundle heavy deps (AWS SDK, Smithy, zod, commander) via deps.optimizer - Exclude tui-harness from unit test project (not production code) - Add afterEach(vi.restoreAllMocks) to 3 files with mock cleanup gaps - Move inline consoleSpy.mockRestore() to afterEach in logs-eval tests - Skip PTY tests when node-pty spawn is unavailable * style: fix prettier formatting in build-and-test.yml * fix: enable include-hidden-files for blob artifact upload upload-artifact@v7 defaults include-hidden-files to false, which skips the .vitest-reports directory. Also fail loudly if no files found. * feat: runtime endpoint support in AgentCore CLI (#979) * feat: add runtime endpoint support to AgentCore CLI - Schema: endpoints field on AgentEnvSpec, runtimeVersion in deployed state - Primitive: RuntimeEndpointPrimitive with add/remove/preview - TUI: Add and Remove flows with multi-field form - Status: endpoints nested under agents with deployment badges - Deploy: parseRuntimeEndpointOutputs + buildDeployedState pipeline * fix: correct output key prefix for runtime endpoint parsing The CFN output keys include the AgentEnvironment construct prefix (Agent{PascalName}) which was missing from the parser pattern. * fix: remove .omc state files and unused useCallback import - Remove .omc/ from git tracking, add to .gitignore - Remove unused useCallback import in AddRuntimeEndpointScreen.tsx * fix: shorten runtime endpoint description to prevent TUI overflow The description "Named endpoint (version alias) for a runtime" was too long and wrapped to the next line in the Add Resource menu. Shortened to "Named endpoint for a runtime". * fix: validate runtime endpoint version is a positive integer - Add explicit Number.isInteger check before schema validation - Change Commander parser from parseInt to Number so floats like 3.5 are caught instead of silently truncated * fix: use agent/endpoint composite key to prevent React key collision Endpoint names can collide across runtimes (e.g., both have "prod"). Changed React key from epName to agent.name/epName to prevent duplicate key warnings that pollute the TUI viewport. * fix: render runtime endpoints in status --type runtime-endpoint When filtering by --type runtime-endpoint, agents array is empty so the agents section (which nests endpoints) never renders. Added a standalone Runtime Endpoints section that shows when endpoints exist but agents don't (i.e., when type-filtering). * fix: add runtime-endpoint to status --help --type documentation The --type option help text was missing runtime-endpoint from the list of valid resource types. * fix: return richer JSON response from add runtime-endpoint add now returns { success, endpointName, agent, version } instead of sparse { success: true }, matching the richer response shape from remove runtime-endpoint. * fix: validate endpoint version against deployed runtime version - TUI: show "Current deployed version: N" and valid range (1-N) - TUI: reject version exceeding latest deployed version - CLI: check deployed-state.json for max version, reject if exceeded - If runtime not deployed, only positive integer check applies * chore: remove planning and bug bash docs from PR * fix: use composite key and parentName for endpoint identification - Add parentName field to ResourceStatusEntry for structured parent linking - Use runtimeName/endpointName composite key in remove/preview/getRemovable - Status command filters endpoints by parentName instead of parsing detail string - React keys use structured parentName/name instead of display strings * test: add comprehensive unit tests for RuntimeEndpointPrimitive 23 tests covering add(), remove(), previewRemove(), getRemovable(): - Runtime lookup, duplicate detection, version validation - Composite key removal targeting correct runtime - Empty endpoints dict cleanup - Version validation against deployed state - Richer JSON response shape * fix: remove dead findGatewayTargetReferences stub * fix: use BasePrimitive configIO instead of ad-hoc ConfigIO in add() * fix: use Number() instead of parseInt in TUI version validation * chore: fix prettier formatting * fix: use T[] instead of Array to satisfy eslint array-type rule * feat: add gateway import command with executionRoleArn support (#855) * feat: add gateway import command and unhide import from TUI Add `agentcore import gateway --arn ` to import existing AWS gateways (with all targets) into a local CLI project. Also remove import from the HIDDEN_FROM_TUI list so it appears in the interactive TUI. - Add AWS SDK wrappers for gateway/target list/get APIs - Add import-gateway.ts with multi-resource CFN import support - Add resourceName schema field to preserve actual AWS gateway name during import - Register gateway in TUI ImportSelectScreen and ImportProgressScreen - Extend ARN pattern, deployed state, and CFN constants for gateway type * fix: expand ARN input to show full resource ARN and add gateway support The ARN text input was truncating long ARNs. Use the expandable prop to wrap text across multiple lines. Also add gateway to the ARN validation pattern and resource type labels. * refactor: remove --name and --yes flags from import gateway command Remove --name (confusing local rename) and --yes (no prompts to confirm) from the gateway import command. The gateway's AWS name is used directly. * feat: add e2e tests for import gateway command Add end-to-end tests that create a real AWS gateway with an MCP server target, import it via `agentcore import gateway --arn`, and verify the resulting agentcore.json fields and deployed-state.json entries. New files: - e2e-tests/fixtures/import/setup_gateway.py: creates gateway + target - e2e-tests/fixtures/import/common.py: gateway wait helpers - e2e-tests/fixtures/import/cleanup_resources.py: gateway cleanup Constraint: Tests follow the existing import-resources.test.ts pattern Confidence: high Scope-risk: narrow * chore: gitignore bugbash-resources.json and .omc/ * feat: preserve gateway executionRoleArn during import Extract roleArn from the AWS GetGateway response and map it to executionRoleArn in agentcore.json. On deploy, CDK uses iam.Role.fromRoleArn() instead of creating a new role, keeping the original permissions intact. Constraint: imported roles use mutable: false so CDK cannot modify them Rejected: always create new role | breaks permissions on re-import Confidence: high Scope-risk: narrow * refactor: export internal gateway import functions for unit testing Add @internal exports for toGatewayTargetSpec, resolveOutboundAuth, toGatewaySpec, and buildCredentialArnMap to enable direct unit testing of the pure mapping functions in import-gateway.ts. Confidence: high Scope-risk: narrow * test: add unit tests for mcpServer target mapping and credential resolution Bugbash coverage for toGatewayTargetSpec and resolveOutboundAuth: - mcpServer with no auth, OAuth, and API_KEY credentials - Credential resolution warnings when ARNs not in project - Targets with no MCP configuration - OAuth scopes pass-through and empty scopes omission 8 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for apiGateway, openApiSchema, smithyModel, lambda target mapping Bugbash coverage for toGatewayTargetSpec non-mcpServer target types: - apiGateway: restApiId, stage, toolFilters, toolOverrides mapping - openApiSchema: S3 URI mapping, missing URI warning - smithyModel: S3 URI mapping, missing URI warning - lambda: S3 tool schema to lambdaFunctionArn mapping, missing ARN, inline-only schema warning, progress messages - Unrecognized target type warning 13 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for toGatewaySpec gateway-level field mapping Bugbash coverage for toGatewaySpec AWS-to-CLI schema mapping: - Authorizer types: NONE, AWS_IAM, CUSTOM_JWT with all JWT fields - CUSTOM_JWT customClaims with full claim structure - Semantic search: SEMANTIC/KEYWORD/missing protocolConfiguration - Exception level: DEBUG/undefined/other values - Policy engine: ARN name extraction, mode preservation - Optional fields: resourceName, description, tags, executionRoleArn - Edge cases: empty tags object omitted, empty JWT arrays omitted 23 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for handleImportGateway full flow validation Bugbash coverage for the main gateway import flow: - Happy path: successful import with --arn, config written, result verified - Rollback: pipeline failure restores original config, noResources error - Duplicate detection: name collision, resource ID already tracked - Name validation: invalid name regex, --name override preserves resourceName - Auto-select: single gateway auto-selected, multiple gateways error, no gateways error - Target mapping: skipped targets warning, non-READY gateway continues 12 tests, all passing. Confidence: high Scope-risk: narrow * test: add unit tests for buildCredentialArnMap and CFN template matching Bugbash coverage for credential resolution and CFN resource matching: - buildCredentialArnMap: reads ARN-to-name map from deployed state, handles multiple credentials, empty/missing state, thrown errors - findLogicalIdByProperty: gateway by Name property, resourceName fallback, target by Name, Fn::Join/Fn::Sub intrinsic function patterns, regex boundary check prevents false substring matches - findLogicalIdsByType: single gateway fallback, single target fallback, multiple targets prevent fallback 14 tests, all passing. Confidence: high Scope-risk: narrow * fix: exclude already-deployed logical IDs when building import resource list When a project already contains an imported resource (gateway + target, agent, memory, etc.), a subsequent import of a different resource that shares a Name with the deployed one caused buildResourcesToImport to resolve the OLD logical ID via findLogicalIdByProperty. The resulting CFN change set then failed with "Resources [...] passed in ResourceToImport are already in a stack and cannot be imported." Thread the deployed template into every buildResourcesToImport callback and skip logical IDs already present in the stack during both the name lookup and the single-candidate fallback. Constraint: GatewayTarget has no structural parent ref in Properties — only the physical-ID tuple (GatewayIdentifier, TargetId), so scoping the synth search by parent gateway is not available. Rejected: Parse Fn::Ref/Fn::GetAtt from GatewayIdentifier | brittle intrinsic traversal Rejected: Match by physical TargetId | synth template has no physical ID for new resources Rejected: Strip deployed resources from synth before lookup | breaks buildImportTemplate Confidence: high Scope-risk: narrow Directive: new callbacks into executeCdkImportPipeline must accept and honor the deployedTemplate arg Not-tested: multi-region / cross-stack-identifier collisions * fix(import): translate AccessDenied on GetGateway to a friendly not-found error When importing a gateway by a well-formed but nonexistent ARN, the BedrockAgentCore control plane returns AccessDenied (not ResourceNotFound) for bedrock-agentcore:GetGateway. The CLI surfaced the raw SDK error — which is misleading when the caller has full Admin access and the gateway simply doesn't exist. Catch AccessDenied from getGatewayDetail and return a targeted failure with guidance: the gateway is likely nonexistent / the ARN is malformed / the caller lacks GetGateway. Point the user at list-gateways so they can confirm. Constraint: AWS returns AccessDenied instead of ResourceNotFound for nonexistent gateway IDs; we cannot distinguish the two server-side Rejected: Client-side ARN existence probe via ListGateways | extra latency on the happy path and still racy Confidence: high Scope-risk: narrow Directive: Do not swallow other error classes here — only AccessDenied is reinterpreted * fix(import): detect AWS_REGION / ARN region mismatch before import Previously when a user ran import with AWS_REGION=us-west-2 against a us-east-1 ARN, and no deployment targets existed yet, the CLI silently synthesized a default target from the ARN's region and proceeded — so the user would unknowingly import from a different region than they intended, leaving agentcore.json pointed at the wrong region and causing cross-region CFN errors on later deploy. Short-circuit resolveImportTarget when AWS_REGION (or AWS_DEFAULT_REGION) is set and disagrees with the ARN's region, and ask the user to reconcile explicitly. Constraint: Must fail fast before any side effects (writing aws-targets.json, calling GetGateway) Rejected: Warn-and-continue | a silent cross-region import is exactly the failure mode we're preventing Confidence: high Scope-risk: narrow Directive: Only throw when both env region AND ARN region are present — do not require AWS_REGION to be set * fix(import): allow re-import of resource after remove without CDK pipeline After `agentcore remove gateway`, the gateway entry remains in deployed-state.json (correctly, since CFN still manages it) but is removed from agentcore.json. A subsequent `agentcore import gateway` would reject with "already imported" because the dedup check only looked at deployed-state. Now, when a resource exists in deployed-state but not in agentcore.json, the import skips the CDK pipeline and just re-adds the resource to agentcore.json. Applies to both the gateway-specific and generic import orchestrators. * style: fix prettier formatting for import-utils and ArnInputScreen * fix(import): address PR review blockers B4, B6, B7, H2, H5, H7, H8 - B4: Hard-fail when credential provider ARN is not found in deployed state instead of silently dropping outboundAuth - B7: Preserve outboundAuth on lambda→lambdaFunctionArn mapping and allow OAUTH/NONE auth types for lambdaFunctionArn targets - H2: Remove re-import fast path; run full CDK pipeline so out-of-band targets are properly imported. Treat noResources as success for re-imports since all resources are already in the CFN stack - H5: Replace hardcoded arn:aws: with partition-agnostic arn:[^:]+: in ARN validation and region extraction regexes - H7/H8: Add regex validation and max length for executionRoleArn, use GatewayNameSchema for resourceName, add refine ensuring both fields are set together or both omitted * fix(import): remove credential ARN from error messages to resolve CodeQL alert CodeQL flagged clear-text logging of credential provider ARNs. The target name provides sufficient context for the user to identify the issue. * fix(import): remove resourceName/executionRoleArn co-variance refine (#996) The refine required both fields to be set together, but resourceName is always needed on import (to preserve the AWS name) while executionRoleArn is only present when the gateway has a custom role. Gateways without a custom role (service auto-creates one) fail the refine because resourceName is set but executionRoleArn is not. Keep the individual field validations (GatewayNameSchema for resourceName, regex for executionRoleArn). * fix(e2e): separate gateway import test and add PR-changed test detection (#999) Split gateway import e2e tests into their own file so they can run independently with faster setup (only setup_gateway.py instead of all 4 resource scripts). Update the PR e2e workflow to detect changed test files and include them alongside the strands-bedrock baseline, using only the main CDK source to reduce CI time. Constraint: PR workflow must always run strands-bedrock as a baseline Rejected: Keep gateway in combined suite | setup creates unnecessary resources when running gateway-only Confidence: high Scope-risk: narrow * fix(e2e): add debug logging for gateway import CI failures (#1001) * fix(e2e): add debug logging for gateway import failures Print the import log file and CloudFormation stack events when the gateway import test fails to help diagnose IMPORT_ROLLBACK_IN_PROGRESS errors in CI. Confidence: high Scope-risk: narrow * fix(e2e): add shared debug logging for all import test failures Add dumpImportDebugInfo to e2e-helper that prints the import log file and CloudFormation stack events when an import fails. Used by both import-resources and import-gateway tests to diagnose CI failures. Confidence: high Scope-risk: narrow * chore: bump version to 0.12.0 (#1002) Co-authored-by: github-actions[bot] * test: remove 44 render-only and framework-testing tests (#998) * 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. * 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 * feat: add CloudWatch traces API for web UI (#997) * fix: remove CONFIG_DIR exclusion from zip stage to preserve dependency agentcore/ packages (#1015) PR #844 correctly removed the flat name-based agentcore exclusion and threaded rootDir through copySourceTree, but the same CONFIG_DIR check remained in collectFiles/collectFilesSync (the zip stage). Since the zip stage operates on the staging directory — not the project root — the check incorrectly stripped any top-level agentcore/ Python package installed by uv (e.g., langgraph_checkpoint_aws/agentcore/) from the deployment artifact, causing ModuleNotFoundError at runtime. The CONFIG_DIR exclusion is only needed in copySourceTree (which copies from the project root into staging). By the time we zip, the project config dir was already filtered out — the only agentcore/ in staging is a legitimate dependency package. Closes #843 * ci: add coordinated main + preview release workflow (#995) * ci: add coordinated main + preview release workflow Adds a single workflow_dispatch that releases both branches together, ensuring they stay in sync on npm. * fix: address review — bump script compat, pre-publish verification, drop unused artifacts - Preview bump now uses `prerelease --prerelease-tag preview` which the bump-version.ts script actually accepts - Added verify-merges job that checks both main and preview have the expected versions before either publish runs (prevents drift) - Both publish jobs now depend on verify-merges instead of each other, so neither publishes unless both PRs are confirmed merged - Removed upload-artifact steps from test jobs since publish jobs rebuild from source post-merge * fix: auto-rebase preview onto main in preflight step Instead of failing when preview isn't rebased, the workflow now rebases automatically. If there are conflicts, it aborts and directs the user to resolve manually. * ci: add sync-preview workflow, simplify release preflight - New sync-preview.yml: runs on every push to main, auto-rebases preview onto main. Silently skips on conflicts (no failure). - Release workflow preflight reverted to a simple check — relies on sync-preview having already done the rebase. * fix: use merge instead of rebase for preview sync Rebase overwrites preview-specific values (package version, tests). Merge preserves preview's divergent files and only conflicts when both branches touch the same lines. * fix: concurrency control, conflict notifications, CDK tag TODO - Add concurrency group to sync-preview to prevent parallel race - On merge conflict, auto-create a GitHub issue (deduplicated) instead of silently skipping - Add TODO comment for CDK preview dist-tag in prepare-preview * fix: create PR with conflict markers instead of issue on merge conflict On conflict, sync-preview now: - Creates a branch with the merge conflict markers committed - Opens a PR targeting preview with resolution instructions - Tags the original commit author for visibility - Deduplicates (skips if a sync PR is already open) * chore(deps): bump the aws-sdk group with 14 updates (#1024) Bumps the aws-sdk group with 14 updates: | Package | From | To | | --- | --- | --- | | [@aws-sdk/client-application-signals](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-application-signals) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agentcore](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-agentcore-control](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agentcore-control) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cloudformation](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudformation) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cloudwatch-logs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cloudwatch-logs) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-resource-groups-tagging-api](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-resource-groups-tagging-api) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-xray](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-xray) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/credential-providers](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/credential-providers) | `3.1037.0` | `3.1038.0` | | [@aws-sdk/client-cognito-identity-provider](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-cognito-identity-provider) | `3.1037.0` | `3.1038.0` | Updates `@aws-sdk/client-application-signals` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-application-signals/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-application-signals) Updates `@aws-sdk/client-bedrock` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock) Updates `@aws-sdk/client-bedrock-agent` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agent) Updates `@aws-sdk/client-bedrock-agentcore` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agentcore) Updates `@aws-sdk/client-bedrock-agentcore-control` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agentcore-control/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-agentcore-control) Updates `@aws-sdk/client-bedrock-runtime` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-bedrock-runtime) Updates `@aws-sdk/client-cloudformation` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudformation/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cloudformation) Updates `@aws-sdk/client-cloudwatch-logs` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cloudwatch-logs/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cloudwatch-logs) Updates `@aws-sdk/client-resource-groups-tagging-api` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-resource-groups-tagging-api/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-resource-groups-tagging-api) Updates `@aws-sdk/client-s3` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-s3) Updates `@aws-sdk/client-sts` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-sts) Updates `@aws-sdk/client-xray` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-xray/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-xray) Updates `@aws-sdk/credential-providers` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/credential-providers/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/packages/credential-providers) Updates `@aws-sdk/client-cognito-identity-provider` from 3.1037.0 to 3.1038.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-cognito-identity-provider/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1038.0/clients/client-cognito-identity-provider) --- updated-dependencies: - dependency-name: "@aws-sdk/client-application-signals" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agent" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-agentcore-control" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-bedrock-runtime" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudformation" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cloudwatch-logs" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-resource-groups-tagging-api" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-xray" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/credential-providers" dependency-version: 3.1038.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-sdk - dependency-name: "@aws-sdk/client-cognito-identity-provider" dependency-version: 3.1038.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-sdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the aws-cdk group with 2 updates (#1025) Bumps the aws-cdk group with 2 updates: [@aws-cdk/toolkit-lib](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/@aws-cdk/toolkit-lib) and [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib). Updates `@aws-cdk/toolkit-lib` from 1.24.0 to 1.25.0 - [Release notes](https://github.com/aws/aws-cdk-cli/releases) - [Commits](https://github.com/aws/aws-cdk-cli/commits/@aws-cdk/toolkit-lib@v1.25.0/packages/@aws-cdk/toolkit-lib) Updates `aws-cdk-lib` from 2.250.0 to 2.251.0 - [Release notes](https://github.com/aws/aws-cdk/releases) - [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md) - [Commits](https://github.com/aws/aws-cdk/commits/v2.251.0/packages/aws-cdk-lib) --- updated-dependencies: - dependency-name: "@aws-cdk/toolkit-lib" dependency-version: 1.25.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: aws-cdk - dependency-name: aws-cdk-lib dependency-version: 2.251.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: aws-cdk ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @opentelemetry/resources from 2.6.1 to 2.7.0 (#1026) Bumps [@opentelemetry/resources](https://github.com/open-telemetry/opentelemetry-js) from 2.6.1 to 2.7.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/v2.6.1...v2.7.0) --- updated-dependencies: - dependency-name: "@opentelemetry/resources" dependency-version: 2.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump @secretlint/secretlint-rule-preset-recommend (#1028) Bumps [@secretlint/secretlint-rule-preset-recommend](https://github.com/secretlint/secretlint) from 12.2.0 to 12.3.1. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v12.2.0...v12.3.1) --- updated-dependencies: - dependency-name: "@secretlint/secretlint-rule-preset-recommend" dependency-version: 12.3.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump secretlint from 12.2.0 to 12.3.1 (#1029) Bumps [secretlint](https://github.com/secretlint/secretlint) from 12.2.0 to 12.3.1. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v12.2.0...v12.3.1) --- updated-dependencies: - dependency-name: secretlint dependency-version: 12.3.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump @opentelemetry/sdk-metrics from 2.6.1 to 2.7.0 (#1030) Bumps [@opentelemetry/sdk-metrics](https://github.com/open-telemetry/opentelemetry-js) from 2.6.1 to 2.7.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/v2.6.1...v2.7.0) --- updated-dependencies: - dependency-name: "@opentelemetry/sdk-metrics" dependency-version: 2.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(ci): update snapshots after CDK version sync in release workflow (#1033) The release workflow syncs @aws/agentcore-cdk to the latest npm version in the asset template, but never updates the snapshot file. This causes the asset snapshot test to fail because the snapshot still holds the old version string. * fix(ci): enable coverage collection in sharded unit test runs (#1034) The coverage report on PRs was empty (0/0 Unknown%) because the sharded unit-test jobs ran without --coverage. Without that flag, V8 coverage data is never collected, so the blob reports contain no coverage maps. The merge-reports step then merges undefined entries and produces empty results. Also fixes the coverage report action referencing a nonexistent vitest.unit.config.ts (should be vitest.config.ts). * fix(ci): move snapshot update after build in release workflow (#1036) Move `npm run test:update-snapshots` from inside the CDK sync step (before build) to its own step after `npm run build`. The test suite needs built artifacts to pass — running it before the build caused 18 test failures. * fix(ci): install uv in release workflow prepare steps (#1038) The create.test.ts tests require uv for Python project scaffolding. The Build and Test workflow installs it via astral-sh/setup-uv, but the release workflow's prepare steps were missing it, causing test failures during the snapshot update step. * chore: bump version to 0.12.1 * fix: remove CDK version auto-sync from release workflow and restore caret range (#1044) Remove the auto-sync step that bumped @aws/agentcore-cdk during releases — version updates will be managed manually via PRs. Restore the caret range (^0.1.0-alpha.19) in the asset and snapshot that was dropped by the auto-sync in #1042. * fix: add Accept header to HTTP protocol invocation proxy (#1051) The dev web UI proxy for HTTP protocol agents doesn't include an Accept header when forwarding requests to /invocations. The bedrock-agentcore runtime SDK checks for Accept: text/event-stream before enabling SSE streaming (via @fastify/sse reply.sse), so streaming handlers always get a 406 response in the inspector. A2A and AGUI protocol paths already send the header correctly — this brings HTTP in line with them. Using "text/event-stream, */*" so streaming agents get SSE enabled while non-streaming agents can respond in whatever format they need. * feat: add telemetry audit mode with FileSystemSink (#1014) * feat: add FilesystemSink for telemetry audit mode * feat: instrument help.modes with telemetry, add audit integ test * refactor: move harness resources to .github/harness/ (#992) * refactor: move harness resources to .github/harness/ Move PR reviewer harness files into a dedicated .github/harness/ directory, separate from the general .github/scripts/ used by Strands workflows. - Move harness_review.py, prompts/ to .github/harness/ - Add Dockerfile for the harness container (dual-token: CLONE_TOKEN for git clones, GITHUB_TOKEN for gh CLI/PR comments) - Add README documenting the harness directory - Update pr-ai-review workflow to reference new path - Update .prettierignore for new prompts location * fix(harness): update Dockerfile comment to accurately describe token handling Tokens are baked into image layers at build time — the previous comment incorrectly implied they were not stored. Updated to make the security posture explicit: the image itself must be treated as a secret. * refactor(harness): use boto3 invoke_harness instead of raw SigV4 HTTP Replace manual SigV4 signing + urllib3 + EventStreamBuffer parsing with the native boto3 bedrock-agentcore client's invoke_harness method. This simplifies the code significantly and leverages the typed event stream response from the SDK. Rejected: keep raw HTTP approach | boto3 now supports invoke_harness natively Confidence: high Scope-risk: narrow Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * Revert "refactor: move harness resources to .github/harness/ (#992)" This reverts commit aef3890e460a9a06db7f8465a157588bc4b0f7b3. * refactor: move harness resources to .github/harness/ and use boto3 invoke_harness - Move harness_review.py, prompts/ to .github/harness/ - Add Dockerfile for the harness container (dual-token setup) - Add README documenting the harness directory - Update pr-ai-review workflow to reference new path - Replace manual SigV4 signing + urllib3 with native boto3 invoke_harness - Update .prettierignore for new prompts location * feat: update @aws/agent-inspector to 0.3.0 * fix(harness): add error handling for invoke_harness API call (#1056) Wrap the invoke_harness_streaming call in a try/except so boto3 errors (bad credentials, network issues, invalid ARN) produce a clean error message instead of a raw traceback in GitHub Actions logs. * chore: bump version to 0.12.2 Co-authored-by: github-actions[bot] * ci: cut full e2e time in half via vitest sharding (#1016) * ci: shard e2e full suite across 6 runners - Add 6-way vitest sharding to the cdk-source matrix (2 → 12 parallel runners) - Isolate import test resources per run via RESOURCE_SUFFIX to prevent concurrent conflicts * fix: import RESOURCE_SUFFIX in cleanup_resources.py * refactor: consolidate cli-config into global-config (#802) * feat: make parsing resilient to individual failures (#1062) * fix: forward custom headers in bearer token invoke paths (#1065) Custom headers passed via -H/--header were silently dropped when using CUSTOM_JWT auth because invokeWithBearerToken and invokeWithBearerTokenStreaming did not merge options.headers into the request headers. Extract buildBearerInvokeHeaders() (paralleling the existing buildMcpBearerHeaders) and use it in both invoke paths. Closes #1052 * feat: wire telemetry into all add.* commands (#1050) * feat: wire telemetry withCommandRun into all add.* commands * refactor: extract cliCommandRun helper, apply to all add.* primitives * test: add audit file assertions for all add.* telemetry * test: add telemetry audit assertions to existing add integ tests * refactor: extract shared audit test utils into src/test-utils/audit.ts * fix: address review feedback — guard telemetry init, replaceAll, unknown fallback, TUI try/catch * fix: AgentPrimitive TUI try/catch, standardize uses safeParse * refactor: extract standalone assertTelemetry helper * refactor: rename audit.ts to telemetry-helper.ts, clarify method names * refactor: move assertTelemetry into TelemetryHelper as assertMetricEmitted * feat: add telemetry to TUI add paths via withAddTelemetry * fix: review feedback — withAddTelemetry safety, standardize handles undefined, MCP agent attrs, policy TUI attrs * fix: remove unnecessary type assertion * fix: address review — document standardize cast, add policy-engine + episodic telemetry tests * refactor: centralize gateway target type mapping in common-shapes * fix: preserve original function error with telemetry wrapper * refactor: extract telemetryAttrs into a single line * feat: wire up telemetry for addAgent * fix: resolve e2e import test concurrency races (#1067) * fix: resolve e2e import test concurrency races Fix two independent concurrency issues causing flaky e2e import tests: 1. TOCTOU race in evaluator import (import-evaluator.ts): The beforeConfigWrite hook lists all online eval configs then fetches details for each with Promise.all. If a config is deleted between the list and get calls, the API throws 'Online evaluation configuration not found' and the entire import fails. Fixed by using Promise.allSettled and filtering out disappeared configs. 2. Resource name collisions across parallel CI shards (setup_*.py): Python setup scripts generated resource names using int(time.time()) (second-level precision). Parallel CI shards starting in the same second would collide with ConflictException. The test already passes a unique RESOURCE_SUFFIX env var but scripts ignored it for naming. Added NAME_SUFFIX to common.py that prefers RESOURCE_SUFFIX when set, and updated all setup scripts to use it. * chore: remove unused time imports from setup scripts * feat: evo preview features — config bundles, batch evaluation, recommendations, AB testing (#1068) * feat: add sync workflow * fix: formatting * fix: only sync to main branch * fix: codeql permissions * feat: add configuration bundle support Add ConfigBundle as a new resource type with full lifecycle: - Schema: ConfigBundleSchema with name validation, component configurations - Primitive: ConfigBundlePrimitive for add/remove operations - API client: SigV4-signed HTTP requests for config bundle CRUD operations - Deploy: post-deploy hook to sync config bundles with control plane - Status: config-bundle resource type in status command - TUI: add wizard (name, description, components, branch, commit message), remove flow, ResourceGraph integration - State: carry forward configBundles across redeploys in buildDeployedState * fix: use correct SigV4 service name for config bundle API The signing service must be 'bedrock-agentcore' for all stages, not 'bedrock-agentcore-control' for prod. The endpoint hostname differs from the signing service name. * fix: config bundle deploy and TUI defaults - Add config bundle post-deploy setup to TUI deploy flow (useDeployFlow) - Add clientToken to config bundle update API call - Add parentVersionIds on update (required by API) - Default branchName to "main" and commitMessage when not specified - Add placeholders for branch/message in TUI wizard - Fallback to find-by-name or create when update fails (stale IDs) - Remove debug logging from actions.ts * fix: use nullish coalescing for branchName default * feat: add edit config-bundle command with deploy diff check - Add `agentcore edit config-bundle` CLI command with --bundle, --components, --components-file, --description, --branch, --message, --json flags - Add interactive TUI wizard for editing config bundles (select bundle, input method, components, commit message, branch name, confirm) - Add diff check to post-deploy: skip API update when components and description are unchanged, avoiding unnecessary version creation - Use getConfigurationBundleVersion instead of getConfigurationBundle to avoid branch-not-found errors on bundles created with different branches - Align default branch name to 'mainline' (API default) instead of 'main' - For updates, inherit branch from current API state when not specified * test: add unit tests for edit config-bundle and deploy diff check - post-deploy-config-bundles: 13 tests covering create, update, skip (diff check), delete, branch inheritance, fallback paths, errors - ConfigBundlePrimitive.edit: 7 tests covering component updates, optional field handling, missing bundle errors, field preservation - useEditConfigBundleWizard: 16 tests covering step navigation, setters, goBack, reset, currentIndex tracking, step labels * fix: address review comments * fix: remove duplicate config-bundle subcommand from edit command * feat: config bundle version history CLI + TUI (#46) * chore: remove edit config-bundle command Users should edit agentcore.json directly to update config bundles. Removes the edit CLI command, TUI screens, wizard hooks, and tests. * feat: add config-bundle CLI commands for version history Adds `agentcore config-bundle` with three subcommands: - `versions` — list version history grouped by branch - `get-version` — view specific version details and components - `diff` — client-side deep diff between two versions Also adds filter support (branchName, latestPerBranch, createdBy) to the listConfigurationBundleVersions API client. * feat: add config bundle hub TUI screens Add TUI screens for browsing config bundles, viewing version history with branch grouping, version detail drill-down, and diff comparison between versions. * fix: resolve config bundle versionId when falling back to list API (#49) The Recommendation API requires versionId to be non-null when using configurationBundle input. When resolveBundleByName fell back to the list API (bundle not in deployed state), it returned no versionId, causing a 400 validation error. Now calls getConfigurationBundle after list to fetch the latest versionId. Also adds versionId to the ResolvedBundle interface and returns it from the deployed-state fast path. * chore: remove get-version subcommand from config-bundle CLI The versions --json and diff commands cover all practical use cases. Keeps the command surface lean: versions + diff only. * feat: add Recommendations API, TUI wizard, and CLI commands (#45) * feat: add Recommendation API wrappers, CLI commands, and operations layer Implement the Recommendations/Optimization feature for AgentCore CLI: - SigV4-signed HTTP client for Start/Get/List/Delete Recommendation (DP) - Operations layer with orchestration, polling, and local storage - CLI commands: evals recommend, evals recommendation history/delete, run promote - 27 unit tests covering API, storage, and orchestration logic - Live-validated field names and ARN formats against prod API * feat: add recommendation TUI wizard with session discovery and multi-evaluator support - Add full recommendation wizard TUI (type, agent, evaluators, input, trace source, sessions, confirm) - Add session discovery flow: discover sessions from CloudWatch, multi-select specific sessions - Support both CloudWatch logs and session ID trace sources - Pass selected sessionIds to recommendation API cloudwatchLogs config - Add request ID capture and error detail extraction for debugging FAILED recommendations - Fix recommendation API test mocks (add headers for requestId capture) - Add scrollable list support (maxVisibleItems) to MultiSelectList, SelectList, WizardSelect - Wire recommendation screen into App.tsx and EvalHubScreen navigation * feat: add session span fetching, recommendation tests, and TUI integration - Add fetch-session-spans module for retrieving OTEL spans from aws/spans and log records from runtime log groups with session ID filtering - Add comprehensive tests for fetch-session-spans (9 tests) and extend run-recommendation tests (12 new tests covering file input, spans-file trace source, tool-desc auto-fetch, error handling, ARN passthrough) - Wire recommendation hub, history screen, and list/delete CLI commands - Update TUI routing for recommendation flows from eval and run hubs - Add recommendation constants (poll intervals, terminal statuses) * chore: remove list commands and promote stub, fix agents→runtimes rename Remove `agentcore list recommendations` and `agentcore list recommendation --id` commands (top-level `list` command deleted entirely). Remove `run promote` stub. Fix typecheck errors from agents→runtimes schema rename in recommendation files. * feat: batch evaluation — stateless eval API, TUI wizard, local storage (#26) * feat: add EvaluationJob resource — schema, primitive, deploy hook, TUI, and tests Phase 1 of EvalJobRunner: CRUD + deploy integration for the EvaluationJob control plane resource. - Schema: EvaluationJobSchema in agentcore.json, deployed state tracking - Primitive: EvaluationJobPrimitive with add/remove lifecycle - AWS client: SigV4-signed HTTP wrappers for EvalJob CP operations - Deploy: post-deploy hook creates/updates/deletes eval jobs imperatively - CFN outputs: parse eval job execution role ARN from stack outputs - TUI: add evaluation-job wizard flow + remove flow integration - Tests: 53 tests across schema, primitive, AWS client, deploy hook, and TUI * feat: add `run evaluation-job` command with DP API wrappers and orchestration - Data plane API wrappers (RunEvaluationJob, GetEvaluationJobRun, ListEvaluationJobRuns) with SigV4 signing against bedrock-agentcore service - Orchestration: resolve job from deployed state, generate runId, start run, poll for completion, fetch results from CW Logs output group - CLI command: `agentcore run evaluation-job --job --session-id ` with --json output and progress callbacks - Tests: 17 new tests covering DP wrappers, runId generation, orchestration (error handling, polling, CW Logs result parsing) * feat: complete US1/US2 quick wins — run name, cancel, update, stage-aware endpoints - Add --run flag to `run evaluation-job` for custom run name prefixes - Add `run cancel-evaluation-job` command with StopEvaluationJobRun DP API - Add `update evaluation-job` primitive method and CLI subcommands - Add `agentcore update experiment` parent command (backward-compatible) - Make CP/DP endpoints stage-aware via AGENTCORE_STAGE env var (beta/gamma/prod) - Fix beta SigV4 service name (bedrock-agentcore vs bedrock-agentcore-control) - Update AddEvaluationJobFlow success screen with next-steps guidance * feat: add TUI run wizard, progress steps, and local result storage for eval jobs - Add RunEvalJobFlow TUI: select job → enter sessions → name run → confirm → execute - Add StepProgress display during eval job polling (starting → polling → fetching → saving) - Add elapsed time counter during run execution - Add eval-job-storage module: save/load/list run results per job in .cli/eval-job-results/ - Auto-save results on both CLI and TUI paths - Add "Evaluation Job" option to TUI Run screen - Add 9 unit tests for eval-job-storage * feat: add CloudWatch session discovery to eval job TUI wizard - Add source type picker: "Discover from CloudWatch" vs "Enter manually" - Add lookback days input (1-90 days) for CloudWatch discovery - Discover sessions via CW Insights query using agent's runtimeId - Multi-select from discovered sessions with span count + timestamps - Auto-fallback to manual entry when agent not deployed (no runtimeId) - Improve error display: show failed step in StepProgress before transitioning * feat: migrate evaluation from resource CRUD to stateless batch evaluation Replace the old EvaluationJob resource model (create/update/delete via agentcore.json + deploy hooks) with a flat BatchEvaluation API model: - Add `run batch-evaluation` and `run stop-batch-evaluation` CLI commands - Add batch evaluation TUI wizard under the Run menu - Add SigV4 API client for batch eval endpoints (start/get/list/stop) - Add CloudWatch results fetching from outputDataConfig - Remove all old evaluation-job infrastructure: primitive, deploy hook, schema, TUI add/remove screens, CP CRUD operations - Remove evaluationJobs from agentcore.json schema Tested end-to-end on gamma (account 998846730471) with Builtin.Faithfulness evaluator against 3 agent sessions — all returning correct scores. * chore: remove executionRoleArn now that FAS creds are live on gamma The batch evaluation API no longer requires an execution role ARN. Remove the --execution-role CLI option and all executionRoleArn plumbing from the API client and orchestration layer. * Revert "chore: remove executionRoleArn now that FAS creds are live on gamma" This reverts commit f1706ff7ea4b7695d1466e609cde29e38cb00afb. * refactor: move stop-batch-evaluation to top-level stop command Move `agentcore run stop-batch-evaluation` to `agentcore stop batch-evaluation` as a higher-level verb, consistent with pause/resume pattern. * fix: evo cleanup — sync public 0.7.1 + 6 bug fixes (#52) * ci: use draft releases for PR tarballs to avoid notifying watchers (#745) * feat: add code-based evaluator support (#739) * feat: add code-based evaluator support Add managed and external code-based evaluator support across schema, CLI flags, TUI wizard, and template scaffolding. Block code-based evaluators from online eval configs at schema, CLI, and TUI layers. * temp: use pyproject.toml with vendored SDK wheel Vendor the SDK wheel and add binary-aware template rendering until the SDK is published to PyPI. To be removed once the SDK is publicly available. * fix: update asset snapshot and regenerate package-lock.json - Update asset file listing snapshot for new evaluator templates - Regenerate package-lock.json to fix stale aws-cdk bundled dep (@aws-cdk/cloud-assembly-schema 52.2.0 -> 53.11.0) * fix: show correct evaluator type in status display Status command was hardcoding "LLM-as-a-Judge" for all evaluators. Now derives the label from item.config.codeBased to distinguish code-based evaluators. * feat: add additionalPolicies field to managed code-based evaluator config Add additionalPolicies to ManagedCodeBasedConfigSchema supporting both inline .json policy files and managed policy ARNs. Auto-populate with execution-role-policy.json when scaffolding managed evaluators. * revert: remove vendored wheel support and requirements.txt The SDK is now on PyPI (bedrock-agentcore>=1.6.0). Remove: - Binary-aware template rendering (.whl copy logic) - Vendored wheels from evaluator assets - requirements.txt references from scaffold messages pyproject.toml now pulls directly from PyPI. * fix: remove vendored wheel and pin bedrock-agentcore>=1.6.0 Remove the last vendored .whl from src/assets and update pyproject.toml to require bedrock-agentcore>=1.6.0 from PyPI. Update asset snapshot accordingly. * feat(import): add runtime and memory import subcommands with TUI wizard (#763) * feat(import): add runtime and memory import subcommands Add `agentcore import runtime` and `agentcore import memory` subcommands to import existing AWS resources into a CLI project. Includes 2-phase CFN import, source code copying, and shared utilities. Also adds TODO.md tracking entrypoint detection improvement and CFN Phase 2 handler investigation, and IMPORT_TESTING_SUMMARY.md with full E2E test results. Constraint: AWS API returns modified entryPoint array (with otel wrapper), not original Constraint: Commander.js parent options shadow same-named child options Rejected: --source flag on runtime subcommand | conflicts with parent import --source Confidence: high Scope-risk: moderate Not-tested: CFN Phase 2 import for runtimes (service-side HandlerInternalFailure) * fix(import): fail on undetectable entrypoint instead of silent fallback extractEntrypoint() now returns undefined when it cannot find a file with a known extension (.py/.ts/.js) in the API's entryPoint array, instead of silently falling back to main.py. Adds --entrypoint flag so users can specify the entrypoint manually when auto-detection fails. Constraint: AWS API returns modified entryPoint array with otel wrappers, not original Rejected: Silent fallback to main.py | wrong entrypoint causes silent deploy failures Confidence: high Scope-risk: narrow * chore: remove TODO.md and testing summary from branch * test(import): add unit tests for entrypoint detection and runtime import handler 11 tests for extractEntrypoint covering otel wrappers, missing/empty arrays, multiple extensions, and extensionless entries. 8 tests for handleImportRuntime covering entrypoint failure, --entrypoint override, missing --code, nonexistent source path, and duplicate runtime names. * fix(import): address PR review feedback - Validate entrypoint file exists inside --code directory - Improve --code help text to clarify it points to the entrypoint folder - Validate AWS credentials match target account via STS GetCallerIdentity - Fix project name prefix stripping to only strip known prefix, not any underscore - Rename sanitize() to replaceUnderscoresWithDashes() for clarity - Use existing Dockerfile template from assets instead of hardcoded duplicate * refactor: change --id to --arn on import runtime and memory subcommands Users now provide the full resource ARN instead of just the ID. The runtime/memory ID is extracted from the ARN's last path segment. * fix(import): extract reflectionNamespaces for EPISODIC memory strategies toMemorySpec was not mapping reflectionNamespaces from the API response, causing EPISODIC strategy imports to fail Zod schema validation which requires reflectionNamespaces for EPISODIC type strategies. * fix(import): validate ARN format, region, and account before extracting resource ID Previously, --arn was parsed with a blind split('/').pop() with no validation. Now parseAndValidateArn checks the ARN matches the expected format, resource type, and that region/account match the deployment target. * fix(import): throw on missing required fields in getMemoryDetail instead of silent defaults Previously, missing id/arn/name/eventExpiryDuration/strategy.type were silently replaced with empty strings or default values, hiding API response issues that would cause broken imports downstream. * fix(import): detect already-imported resources early and improve CFN error messages Check deployed-state.json before making any config changes to catch resources already imported in the current project. Also detect the "already exists in stack" CFN error and provide a friendlier message explaining the resource must be removed from the other stack first. * feat(import): capture tags during memory import Fetch tags via ListTagsForResource API and include them in the imported memory config. Tags already flow through the CLI schema and CDK construct, they just weren't being read from the API during import. * feat(import): capture encryptionKeyArn during memory import Add encryptionKeyArn to CLI schema, MemoryDetail, and toMemorySpec so imported memories preserve their KMS encryption key configuration. Also update CDK L3 construct to pass encryptionKeyArn through to CfnMemory. * feat(import): capture executionRoleArn during memory import Map the API field memoryExecutionRoleArn to executionRoleArn in CLI schema to match the runtime convention. Also update CDK L3 construct to use an imported role via Role.fromRoleArn when executionRoleArn is provided instead of always creating a new one. * refactor(import): deduplicate actions.ts by reusing import-utils utilities actions.ts reimplemented 5 utilities that already exist in import-utils.ts. Replace local definitions with imports and use updateDeployedState() instead of inline state manipulation. Removed: sanitize(), toStackName(), fixPyprojectForSetuptools(), COPY_EXCLUDE_DIRS, copyDirRecursive() — all duplicates of import-utils.ts. * fix(import): paginate listings, auto-select single result, and preserve runtime config Three import bugs fixed: 1. listAgentRuntimes/listMemories only fetched one page (max 100). Added listAllAgentRuntimes/listAllMemories that paginate via nextToken. 2. Single-result listing incorrectly showed "Multiple found" error. Now auto-selects when exactly one runtime/memory exists. 3. toAgentEnvSpec dropped env vars, tags, lifecycle config, and request header allowlist. Extended AgentRuntimeDetail and getAgentRuntimeDetail to extract these fields (including ListTagsForResource call for tags), and mapped them in toAgentEnvSpec. Confidence: high Scope-risk: moderate Not-tested: pagination with >100 real resources (no integration test account available) * test(import): add tests for pagination, field extraction, auto-select, and env var mapping Tests cover: - listAllAgentRuntimes/listAllMemories pagination across multiple pages - getAgentRuntimeDetail extraction of environmentVariables, tags (via ListTagsForResource), lifecycleConfiguration, requestHeaderAllowlist - toAgentEnvSpec mapping of env vars Record to envVars array, plus direct mapping of tags, lifecycle config, and header allowlist - Single-result auto-select when listing returns exactly 1 runtime - Error cases: empty listings, multiple results, absent fields * feat(import): auto-create deployment target from ARN when none exist When no deployment targets are configured, import runtime/memory now parses the --arn to extract region and account, then creates a default target automatically instead of requiring `agentcore deploy` first. * fix(import): omit runtimeVersion for Container builds Container runtimes have no runtimeVersion from the API, but toAgentEnvSpec was hardcoding PYTHON_3_12 as a fallback. Now runtimeVersion is optional in the schema and only set for non-Container builds. * fix(import): filter API-internal namespace patterns from memory import Memory strategies like SUMMARIZATION and USER_PREFERENCE include auto-generated namespace patterns (e.g. /strategies/{memoryStrategyId}/...) that are API-internal and should not be written to local agentcore.json. Constraint: Only filters namespaces containing {memoryStrategyId} template var Rejected: Strip all namespaces for non-SEMANTIC strategies | would lose user-defined namespaces Confidence: high Scope-risk: narrow * fix(import): show project context error before --code flag validation Commander's requiredOption() for --code runs before the action handler, so users outside a project see "required option not specified" instead of "no agentcore project found". Change to option() so the handler's project context check (step 1) runs first. The --code validation at step 5 still catches missing values after project context is confirmed. Constraint: Commander validates requiredOption before action handlers execute Rejected: Moving project check into a Commander hook | adds complexity for one flag Confidence: high Scope-risk: narrow * fix(import): address bugbash issues for import commands - Invalid ARN now returns "Not a valid ARN" before target resolution - Failed imports roll back agentcore.json and clean up copied app/ dirs - Discovery listings show ARNs (not just IDs) so users can copy them - Remove --target flag from import runtime/memory subcommands - Add description field to AgentEnvSpec schema and wire through import Constraint: Commander validates requiredOption before action handlers Constraint: Rollback is best-effort to avoid masking the original error Rejected: Keep --target on subcommands | silently falls back to default, confusing UX Confidence: high Scope-risk: moderate * feat(import): add interactive TUI wizard for import command Adds a multi-screen TUI flow for importing runtimes, memories, and starter toolkit configs, replacing the silent fall-through that previously occurred when selecting "import" in the TUI. Constraint: onProgress must be injectable so TUI can display step progress Rejected: Single text-input screen for all flows | each import type has different required fields Confidence: high Scope-risk: narrow * fix(import): add early name validation and allow re-import with --name Bug 5: Validate --name against the AgentNameSchema regex before any file I/O operations. Previously, a malicious --name like '../../../etc/pwned' would copy files outside the project directory and set up a Python venv there before schema validation rejected it. Now invalid names are caught immediately with a clear error message. Applied to both import-runtime and import-memory. Bug 6: Allow re-importing the same cloud resource under a different local name when --name is provided. Previously, the deployed-state duplicate check blocked all re-imports by resource ID regardless of --name. Now it only blocks when --name is not provided, and suggests using --name in the error message. When --name is provided, it warns and proceeds. Applied to both import-runtime and import-memory. * revert(import): restore original duplicate-by-ARN blocking behavior Bug 6 is not a bug — blocking re-imports of the same cloud resource ARN is correct because allowing it would create duplicate CFN logical resources referencing the same physical resource, causing deploy failures. Reverts the --name re-import allowance while keeping the Bug 5 early name validation fix. * feat(import): mark import command as experimental * fix(import): wire deploy next-step navigation and show dotfiles in file picker Two TUI fixes for the import flow: 1. ImportFlow now accepts onNavigate prop so selecting "Deploy" from next steps navigates to the deploy screen instead of going back. 2. PathInput gains a showHidden prop; YamlPathScreen uses it so .bedrock_agentcore.yaml is visible in the file picker. * refactor(import): extract shared CDK import pipeline to eliminate duplication The three import handlers (import-runtime, import-memory, actions) all repeated the same CDK build/synth/bootstrap/publish/phase1/phase2/state-update pipeline (~120 lines each). Extract this into executeCdkImportPipeline() in a new import-pipeline.ts module. Also add resolveImportContext() and failResult() helpers to import-utils.ts for shared setup and error handling. Net effect: -335 lines, zero behavior change, all 260 tests pass. Constraint: Must not change any observable behavior — pure structural refactor Rejected: Full strategy-pattern abstraction | over-engineering for 2 concrete cases Confidence: high Scope-risk: moderate Not-tested: actions.ts YAML import path with real AWS (infra limitation) * fix(import): launch TUI wizard when running agentcore import with no args Previously `agentcore import` with no --source flag showed help text. Now it launches the interactive ImportFlow TUI, matching the pattern used by `agentcore add` and other commands. * fix(import): wire deploy and status navigation from CLI-inline TUI When running `agentcore import` from CLI (not full TUI), selecting "deploy" or "status" from the next-steps menu now renders the corresponding screen instead of silently exiting. * style(import): fix prettier formatting in TUI screens * fix(security): update lodash and lodash-es to resolve high-severity vulnerabilities npm audit fix resolves CVE for code injection via _.template and prototype pollution via _.unset/_.omit in lodash <=4.17.23. * refactor(aws): extract createControlClient to avoid per-call client instantiation Each function in agentcore-control.ts was creating a new BedrockAgentCoreControlClient on every call, wasting HTTP connections and credential resolution. Extracted a shared createControlClient() factory and reuse a single client across paginated listAll* calls. * fix(import): log warnings on silent catch failures instead of swallowing errors Tag fetch failures in agentcore-control.ts and rollback failures in import-runtime.ts and import-memory.ts were silently swallowed. Users had no indication when config could be left in a broken state. Added console.warn calls matching the existing pattern in bedrock-import.ts. --------- Co-authored-by: Aidan Daly * fix(ci): regenerate lockfile for npm 11 compatibility (#770) npm 11 (shipped with Node 24.x) requires all optional dependency entries in the lock file, even for non-matching platforms. The lock file was generated with npm 10, which only records the current platform's optional deps (@esbuild/linux-x64). This caused npm ci to fail on the Node 24.x CI matrix entry with "Missing: @esbuild/* from lock file" errors. Regenerated with npm 11 to include all 26 @esbuild/* platform entries. Constraint: Lock file must be compatible with npm 10 (Node 20/22) and npm 11 (Node 24) Rejected: Pin CI to npm 10 | would mask the issue and delay migration Confidence: high Scope-risk: narrow Co-authored-by: Claude Opus 4.6 * ci: block schema changes in PRs (#712) The JSON schema is served live from the repo, so any commit to main that modifies it is effectively a release. Only the release workflow should regenerate and commit schema changes. * chore: bump version to 0.6.0 (#771) Co-authored-by: github-actions[bot] * fix: make add command description consistent with remove (#773) * chore(deps): bump vite from 8.0.3 to 8.0.5 (#777) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 8.0.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(fetch): add --identity-name option for custom credential lookup (#715) (#774) The `fetch access` command hardcoded credential lookup to `-oauth` via `computeManagedOAuthCredentialName()`, causing failures when users create identities with custom names. This adds an `--identity-name` option that lets users specify which credential to use for OAuth token fetch, falling back to the default convention when omitted. When no matching credential is found, the error message now lists all available OAuth credentials and suggests using `--identity-name`. Constraint: Must remain backward compatible — omitting --identity-name preserves existing behavior Rejected: Modify computeManagedOAuthCredentialName globally | would break other consumers Confidence: high Scope-risk: narrow Not-tested: TUI interactive flow and invoke command auto-fetch paths (noted as follow-up) * feat(status): display runtime invocation URL for deployed agents (#775) Show the runtime invocation URL in agentcore status output for each deployed agent. The URL is computed from the runtime ARN and target region, and displayed in CLI text output, JSON output, and the TUI ResourceGraph component. URL format: https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{encodedArn}/invocations Closes #716 Constraint: URL is only available when both targetConfig and runtimeArn exist Rejected: Reuse existing buildInvokeUrl from agentcore.ts | includes ?qualifier=DEFAULT which is for API invocation, not display Confidence: high Scope-risk: narrow * feat(create): add --skip-install flag to skip dependency installation (#782) Adds a --skip-install flag to `agentcore create` that skips all dependency installation (npm install for CDK and uv sync for Python). This enables enterprise users behind corporate proxies or private registries to modify package.json/pyproject.toml before installing dependencies manually. The flag sets the existing AGENTCORE_SKIP_INSTALL env var (previously only used in tests) and also implies --skip-python-setup behavior. A post-create message instructs users to install manually. * feat(import): add evaluator and online eval config import subcommands (#780) * feat(import): add evaluator import subcommand with TUI wizard Add `agentcore import evaluator` to import existing AWS evaluators into CLI projects. Refactor import types and utilities for extensibility so future resource types require minimal new code. Changes: - Add import-evaluator.ts handler with toEvaluatorSpec mapping (LLM-as-a-Judge and code-based evaluators), duplicate detection, and CDK import pipeline - Enhance getEvaluator API wrapper to extract full evaluatorConfig (model, instructions, ratingScale) and tags from SDK tagged unions - Add listAllEvaluators pagination helper filtering out built-in evaluators - Widen ImportableResourceType union and shared utilities for evaluator support - Add evaluator to TUI import flow (select, ARN input, progress screens) - Add 17 unit tests covering spec conversion, template lookup, and error cases Tested end-to-end against real AWS evaluator (bugbash_eval_1775226567-zrDxm7Gpcw) with verified field mapping for all config fields, tags, and deployed state. * fix(import): use correct importType for evaluator in TUI flow The TUI import wizard hardcoded importType as 'memory' for all non-runtime resources, causing evaluator imports to fail with "ARN resource type evaluator does not match expected type memory". Use flow.resourceType instead so the correct handler is dispatched. * feat(import): add online eval config import subcommand Add `agentcore import online-eval` to import existing online evaluation configs from AWS into CLI-managed projects. Follows the same pattern as runtime, memory, and evaluator imports. The command extracts the agent reference from the config's service names (pattern: {agentName}.DEFAULT), maps evaluator IDs to local names or ARN fallbacks, and runs the full CDK import pipeline. Also removes incorrect project-prefix stripping from evaluator and runtime imports — imported resources come from outside the project and won't have the project prefix. Constraint: Agent must exist in project runtimes[] before import (schema enforces cross-reference) Constraint: Evaluators not in project fall back to ARN format to bypass schema validation Rejected: Loose agent validation | schema writeProjectSpec() enforces runtimes[] cross-reference Confidence: high Scope-risk: moderate * feat(import): add online eval config to TUI import wizard Add 'Online Eval Config' option to the interactive import flow so users can import online evaluation configs via the TUI, not just the CLI. Follows the same ARN-only pattern as evaluator and memory imports: select type → enter ARN → import progress → success/error. * docs: add TUI import wizard screenshots for online eval Screenshots captured from the TUI import flow showing: - Import type selection menu with Online Eval Config option - ARN input screen for online eval config - ARN input with a real config ARN filled in * Revert "docs: add TUI import wizard screenshots for online eval" This reverts commit cb4c6757e66ffefe05c974d44e34754cff216196. * refactor(import): extract generic import orchestrator with descriptor pattern Reduce ~1,400 lines of duplicated orchestration across four import handlers (runtime, memory, evaluator, online-eval) to ~600 lines by extracting shared logic into executeResourceImport(). Each resource type now provides a thin descriptor declaring its specific behavior. Constraint: Public handleImport* function signatures unchanged (TUI depends on them) Constraint: Factory functions needed for runtime/online-eval to share mutable state between hooks Rejected: Strategy class hierarchy | descriptor objects are simpler and more composable Confidence: high Scope-risk: moderate * refactor(aws): extract paginateAll and fetchTags helpers in agentcore-control Deduplicates identical pagination loops across 4 listAll* functions and identical tag-fetching try/catch blocks across 3 getDetail functions. Also adds optional client param to listEvaluators and listOnlineEvaluationConfigs for connection reuse during pagination. Addresses deferred review feedback from PR #763. Constraint: evaluator listAll still filters out Builtin.* entries Confidence: high Scope-risk: narrow * fix(import): resolve evaluator references via deployed state for imported evaluators resolveEvaluatorReferences used string-contains matching (evaluatorId.includes(localName)) which only works when the evaluator was deployed by the same project. Imported evaluators with renamed local names never matched, falling back to raw ARNs in the config. Now reads deployed-state.json to build an evaluatorId → localName reverse map and checks it first, before the string-contains heuristic. Constraint: Deployed state may not exist yet (first import) — .catch() handles gracefully Rejected: Passing deployed state through descriptor interface | only online-eval needs this Confidence: high Scope-risk: narrow * fix(import): auto-disable online eval configs to unlock evaluators during import Evaluators referenced by ENABLED online eval configs are locked by the service (lockedForModification=true), causing CFN import to fail when it tries to apply stack-level tags. Now the evaluator import detects the lock, temporarily disables referencing online eval configs, performs the import, then re-enables them. Constraint: Re-enable runs in finally block so configs are restored on both success and failure Constraint: Only disables configs that actually reference this specific evaluator Rejected: Refuse import with manual guidance | user can't pause configs not yet in project Confidence: high Scope-risk: moderate * Revert "fix(import): auto-disable online eval configs to unlock evaluators during import" This reverts commit 583939153e336a72c6e5cd425dd02a834d73b9d0. * fix(import): block evaluator import when referenced by online eval, use ARN-only references Evaluators locked by an online eval config cannot be CFN-imported because CloudFormation triggers a post-import TagResource call that the resource handler rejects. Instead of stripping tags from the import template, block the import with a clear error and suggestion to use import online-eval. Online eval config import now always references evaluators by ARN rather than resolving to local names, since the evaluators cannot be imported into the project alongside the config. Constraint: CFN IMPORT triggers TagResource which fails on locked evaluators Rejected: Strip Tags from import template | still fails on some resource types Confidence: high Scope-risk: narrow * fix(import): resolve OEC agent reference via deployed state when runtime has custom name extractAgentName() derives the AWS runtime name from the OEC service name pattern, but this fails to match when the runtime was imported with --name since the project spec stores the local name. Now falls back to listing runtimes to find the runtime ID, then looks up the local name in deployed-state.json. * fix(import): strip CDK project prefix from OEC service name when resolving agent CDK constructs set the OEC service name as "{projectName}_{agentName}.DEFAULT". extractAgentName() strips ".DEFAULT" but not the project prefix, so the lookup fails against local runtime names. Now strips the prefix as a fast path before falling back to the deployed-state API lookup. * fix(import): show friendly error for non-existent evaluator ID getEvaluator() now catches ResourceNotFoundException and ValidationException from the SDK and rethrows a clear message instead of exposing the raw regex validation error. * fix(import): validate ARN resource type for online-eval import import online-eval used a naive regex to extract the config ID from the ARN, skipping resource type, region, and account validation. Now uses parseAndValidateArn like all other import commands. Added an ARN resource type mapping to handle the online-eval vs online-evaluation-config mismatch between ImportableResourceType and the ARN format. * refactor(import): address PR review feedback - Add `red` to ANSI constants, replace inline escape codes - Type GetEvaluatorResult.level as EvaluationLevel at boundary - Combine ARN_RESOURCE_TYPE_MAP, collectionKeyMap, idFieldMap into single RESOURCE_TYPE_CONFIG to prevent drift - Export IMPORTABLE_RESOURCES as const array, derive type from it, replace || chains with .includes() - Fix samplingPercentage === 0 false positive (use == null) - Document closure state sequencing contract on descriptor hooks * test(import): remove unreachable empty-level evaluator test The test exercised a defensive fallback in toEvaluatorSpec for an empty level string, but now that GetEvaluatorResult.level is typed as EvaluationLevel, the boundary cast in getEvaluator prevents this case from ever reaching toEvaluatorSpec. * feat: add custom dockerfile support for Container agent builds (#783) * feat: add custom dockerfile support for Container agent builds Add an optional `dockerfile` field to Container agent configuration, allowing users to specify a custom Dockerfile name (e.g. Dockerfile.gpu) instead of the default "Dockerfile". Changes across all layers: - Schema: Add dockerfile field to AgentEnvSpecSchema with filename validation - CLI wizard: Add "Custom Dockerfile" option to Advanced settings multi-select, with dedicated Dockerfile input step in the breadcrumb wizard - Dev server: Thread dockerfile through container config to docker build - Deploy preflight: Validate custom dockerfile exists before deploy - Packaging: Pass dockerfile to container build commands - Security: getDockerfilePath rejects path traversal (/, \, ..) - Tests: 64 new/updated tests across schema, preflight, dev config, packaging, wizard, and constants Constraint: Dockerfile must be a filename only (no path separators) Rejected: Accept full paths | path traversal security risk Rejected: Auto-copy Dockerfile on create | users manage their own Dockerfiles Confidence: high Scope-risk: moderate Not-tested: Interactive TUI tested manually via TUI harness (not in CI) * chore: remove dead code and redundant tests from dockerfile PR - Remove unused ADVANCED_GROUP_LABELS constant (dead code) - Remove unnecessary export on DOCKERFILE_NAME_REGEX - Fix stale `steps` dependency in useGenerateWizard setAdvanced callback - Trim computeByoSteps.test.ts to dockerfile-only tests (remove 11 tests for pre-existing behavior unchanged by this PR) - Remove redundant "uses default Dockerfile" tests that duplicate existing coverage in preflight, config, and container packager test files - Consolidate shell metacharacter it.each from 5 cases to 1 representative Confidence: high Scope-risk: narrow * fix: skip template Dockerfile scaffolding when custom dockerfile is set When a custom dockerfile is configured (e.g. Dockerfile.gpu), the renderer was still copying the default template Dockerfile into the agent directory, leaving an unused file alongside the custom one. Thread the dockerfile config through AgentRenderConfig and use a new exclude option on copyAndRenderDir to skip the template Dockerfile when a custom one is specified. The .dockerignore is still scaffolded. Constraint: copyAndRenderDir is a shared utility used by all renderers Rejected: Delete template after render | user requested option A (don't create) Confidence: high Scope-risk: narrow * fix: use PathInput file picker for Dockerfile selection in TUI Replace the TextInput with PathInput for Dockerfile selection in both the BYO add-agent and Generate wizard flows. This gives users a real file browser with directory navigation and existence validation on submit, matching the UX pattern used by the policy file picker. BYO flow: PathInput scoped to the agent's code directory so users browse their existing files and pick a Dockerfile. Generate flow: PathInput scoped to cwd so users browse the filesystem to find a Dockerfile to copy into the new project. Added allowEmpty and emptyHelpText props to PathInput so users can press Enter to use the default Dockerfile. Constraint: PathInput is a shared component used by policy and import screens Rejected: Soft warning on TextInput | user preferred real file picker like policy Confidence: high Scope-risk: narrow * feat(invoke,dev): add exec mode for running shell commands in runtimes (#750) * feat(invoke,dev): add exec mode for running shell commands in runtimes Add ! exec mode to invoke TUI for running shell commands in deployed runtimes, and ! local exec / !! container exec to dev TUI. Includes non-interactive --exec flag for both invoke and dev commands. - invoke TUI: type ! to enter exec mode, runs commands in deployed runtime - invoke CLI: --exec flag runs commands via InvokeAgentRuntimeCommand API - dev TUI: type ! for local exec, !! for container exec (Container builds) - dev CLI: --exec flag execs into running dev container (Container only) - TextInput: add onChange and onBackspaceEmpty props for mode switching - Pink/magenta hint text appears when exec input is empty, disappears on typing - Backspace on empty input reverses mode (!! -> ! -> normal) - IAM policy and docs updated with new bedrock-agentcore:InvokeRuntimeCommand Constraint: dev --exec CLI is Container-only since CodeZip users have local terminal Rejected: Ctrl+E hotkey for container exec | !! double-bang is more discoverable and consistent Confidence: high Scope-risk: moderate * fix: address review findings — side effects, DRY, dead code, validation CRITICAL: Move onBackspaceEmpty callback outside setState updater (React purity violation) and add text.length === 0 guard so it only fires when input is truly empty. HIGH: Extract shared runSpawnCommand helper in useDevServer to DRY up execCommand/execInContainer near-duplication (~100 lines → ~60 lines). Deslop: Remove dead providerInfo/modelProvider code paths, simplify singleValueStream to plain yield, replace options.prompt! assertions with early guard, remove redundant comments. Medium: Fix timeout:0 falsy check, add --exec+--stream validation, handle undefined exitCode explicitly, add SDK cast comment + runtime guard. Constraint: onBackspaceEmpty must fire outside setState to avoid double-fire in React 18 concurrent mode Rejected: Checking state inside updater for callback | React purity violation Confidence: high Scope-risk: narrow * feat(tui): sticky exec mode with distinct visual styling - Exec prompt (!) now uses magenta instead of yellow for clear differentiation from chat prompt (>) - Exec output renders in default terminal foreground, distinct from green chat responses — works on both dark and light terminals - Exec mode is sticky: ! prompt persists after running a command, exit via Escape or Backspace-on-empty - Conversation rendering upgraded to per-line colored output in InvokeScreen (matching DevScreen pattern) Constraint: Terminal color palette limited to 8 base colors Rejected: cyan for exec output | too similar to blue chat input Rejected: yellow for exec output | invisible on light terminal backgrounds Confidence: high Scope-risk: narrow * fix(tui): resolve 3 exec mode UX bugs from review feedback Bug 1: Escape in exec mode no longer exits the app. The Screen component's useExitHandler is disabled when in input mode (exitEnabled={mode !== 'input'}), so only TextInput's onCancel handles Escape. Escape in exec drops to > prompt; Escape from > goes to chat mode; Escape from chat exits. Bug 2: Dim prompt during command execution now shows ! or !! instead of always showing >, matching the current exec state. Bug 3: execInputEmpty is reset to true after command execution, so typing ! in sticky exec mode correctly escalates to !! (container exec) on container agents. * fix(tui): eliminate command flash during exec mode transitions The !! command and prompt would briefly vanish (replaced by >) when executing a container command, because setMode('chat') fired in a separate render batch from setConversation/setIsStreaming. Fix: add onStart callback to runSpawnCommand that fires in the same synchronous block as the conversation/streaming state updates, so React batches them together. The mode transition and conversation update now render atomically — no flash. * chore: bump version to 0.7.0 (#784) Co-authored-by: github-actions[bot] * fix(ci): pin npm version to avoid self-upgrade corruption (#785) npm install -g npm@latest fails on GitHub Actions runners when npm tries to replace its own modules mid-installation, corrupting the promise-retry dependency. Pin to npm@11.5.1 which is the minimum version needed for OIDC trusted publishing. * chore: bump version to 0.7.1 (#786) Co-authored-by: github-actions[bot] * fix: evo cleanup — 6 fixes for config bundles, recommendations, and batch eval 1. Remove "List Recommendations" from TUI hub (unimplemented API call) 2. Config bundle TUI hub reads from project config (agentcore.json) instead of listing all bundles from the API 3. Default config bundle branch name changed from "mainline" to "main" 4. Tool description recommendation: remove evaluator step (API does not accept evaluators); System prompt recommendation: single-select evaluator (exactly 1 required) 5. Tool description TUI: remove duplicate inputSource/content steps (tools step already collects tool name:description pairs) 6. Batch eval: only send executionRoleArn when explicitly provided via --execution-role flag (FAS creds work without it) * fix: standardize CLI flags, error handling, and batch eval TUI - Rename --agent to --runtime in batch eval and recommendation commands - Rename --days to --lookback in run eval command - Make --evaluator optional for tool-description recommendations (matches API) - Standardize evaluator flag descriptions across all run subcommands - Make deleteConfigurationBundle throw instead of returning {success, error} - Remove requestId from recommendation API error messages - Silence non-fatal tag fetch warnings in agentcore-control - Add StepProgress component to batch eval TUI with elapsed timer * feat: surface batch eval evaluatorSummaries and session stats - Add EvaluatorSummary and EvaluationResults types to batch eval API client - Extract evaluationResults from GetBatchEvaluation API response - CLI and TUI prefer API-provided evaluatorSummaries (averageScore, totalEvaluated, totalFailed) over local computation when available - Show session stats (total, completed, failed) in both CLI and TUI - Persist evaluationResults in saved batch eval records - Falls back to local CloudWatch-based aggregation when API summaries are not yet available (gamma currently returns session counts only; evaluatorSummaries is in beta and will roll forward) * docs: improve CLI help text, descriptions, and examples - Fix `run` parent description (was eval-only, now covers all 3 subcommands) - Fix `--agent-arn` reference in copy.ts (renamed to --runtime-arn) - Fix logs example using --agent (now --runtime) - Improve `evals` and `recommendations` descriptions for clarity - Add ground truth context to -A, --expected-trajectory, --expected-response - Improve recommendation option descriptions (--inline, --tools, --bundle-name) - Clarify --lookback as "How far back to search for traces in CloudWatch" - Clarify --evaluator as "required for system-prompt, optional for tool-description" - Remove "temporary — will be removed" from --execution-role - Improve batch eval description to mention CloudWatch sessions - Add CLI_ONLY_EXAMPLES for run eval, run batch-evaluation, run recommendation, config-bundle, stop, and evals commands --------- Signed-off-by: dependabot[bot] Co-authored-by: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Co-authored-by: Trirmadura J Ariyawansa Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Co-authored-by: Aidan Daly Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> * fix(ci): exclude .github/workflows/ from public repo sync (#54) GITHUB_TOKEN lacks the 'workflows' permission, so pushing workflow file changes from the public repo causes the sync to fail. Use --no-commit --no-ff and restore .github/workflows/ from HEAD before committing, in both the clean merge and conflict paths. * fix: evaluator resolution + config bundle filter field name (#55) * fix: resolve custom evaluator names to deployed IDs in batch eval Batch eval was sending project-level evaluator names (e.g. "MyCustomEval") instead of deployed evaluator IDs (e.g. "cbtest_MyCustomEval-xv2w2e42GL"). Builtin.* evaluators worked because the service resolves them directly, but custom evaluators need the deployed ID. Adds evaluator name resolution from deployed state, matching how run eval already resolves custom evaluator names in run-eval.ts. * fix: correct config bundle version filter field name to match API The ListConfigurationBundleVersions API expects `createdByName` (string) in the version filter, but the CLI was sending `createdBy` (string[]). This caused the filter to be silently ignored by the API. * fix: tool description comma parsing in recommendations (#56) CLI --tools option changed from comma-separated string to variadic (--tools "search:desc" --tools "calc:desc") so commas in descriptions don't break parsing. TUI uses a smarter regex split that only splits on commas followed by a valid tool name pattern and colon. * feat: add A/B test CLI support - ABTestPrimitive with add/remove following config bundle pattern - SigV4-signed HTTP client for AgentCore Evaluation Data Plane API - Post-deploy reconciliation creates/deletes AB tests + IAM roles - TUI: add wizard, remove flow, compact dashboard detail screen - CLI commands: add, remove, pause, resume, stop, view details - Auto-creates project-scoped IAM role with least-privilege permissions - Post-deploy warnings surfaced in TUI deploy screen - Comprehensive unit, hook, and integration tests * fix: route config-bundle to interactive TUI instead of CLI-only help screen config-bundle was listed in CLI_ONLY_EXAMPLES which intercepted the command before it could reach the ConfigBundleFlow TUI route, showing a static usage screen instead of the interactive bundle hub. * feat: add A/B test CLI support (#50) - ABTestPrimitive with add/remove following config bundle pattern - SigV4-signed HTTP client for AgentCore Evaluation Data Plane API - Post-deploy reconciliation creates/deletes AB tests + IAM roles - TUI: add wizard, remove flow, compact dashboard detail screen - CLI commands: add, remove, pause, resume, stop, view details - Auto-creates project-scoped IAM role with least-privilege permissions - Post-deploy warnings surfaced in TUI deploy screen - Comprehensive unit, hook, and integration tests * fix: TUI bug fixes + config bundle recommendation support (#57) * fix: batch eval name allows blank + evaluator list scrollable - Add allowEmpty to batch eval name TextInput so users can leave it blank for auto-generated names (placeholder said "leave blank" but validation blocked empty input) - Add maxVisibleItems={10} to evaluator WizardSelect/WizardMultiSelect in both recommendation and batch eval flows to prevent list overflow when many evaluators exist (enables arrow-key scrolling) * feat: add config bundle selection to recommendation TUI wizard Add 'Config bundle' as a third input source option in the recommendation TUI wizard (alongside 'Enter inline' and 'Load from file'). When selected, users can pick from deployed config bundles to use their system prompt as the recommendation input. - Add ConfigBundleItem type and 'bundle' step to wizard flow - Load deployed config bundles during initialization - Pass bundleName/bundleVersion to runRecommendationCommand - Only show config-bundle option when bundles are deployed * fix: config bundle recommendation uses local system prompt instead of broken API path The systemPromptJsonPath field in the config bundle API is broken server-side (all JSON path formats resolve to empty). Work around this by reading the system prompt from the local agentcore.json project config and passing it as inline text to the recommendation API. Also adds config bundle as an always-visible input source option in the recommendation TUI wizard, with proper empty-state handling when no bundles are deployed. * fix: single evaluator + config bundle field selection for recommendations (#61) * fix: enforce single evaluator for recommendations + bundle field selection - Recommendation API accepts exactly one evaluator for system-prompt (min:1, max:1) and none for tool-description. CLI flag changed from variadic --evaluator to singular --evaluator . Operations layer validates count before hitting API. - Config bundle recommendation now lets users pick which configuration field contains the system prompt, instead of guessing key names. New "Prompt Field" wizard step shows all string fields from the selected bundle's configuration. - Validates system prompt content is non-empty before API call, preventing the text:"" → 400 error reported by testers. * feat: add config bundle support for tool description recommendations - TUI wizard now shows config-bundle as input source for tool descriptions - bundleField (singular) → bundleFields (array) to support multi-select - System prompt: single-select picks one field as prompt content - Tool description: multi-select picks fields as toolName:description pairs - Fix confirm screen to display bundleFields correctly - Skip "Tools" confirm field when using config-bundle input * fix: use single-select for bundle field when only one field available - Tool desc with 1 field: WizardSelect (Enter to pick) - Tool desc with 2+ fields: WizardMultiSelect (Space to toggle) - Footer shows "Space to select" hint for multi-select mode * feat: add lookback days and session selection to batch evaluation - AWS client: add sessionInput (sessionIds + sessionFilterConfig) to CloudWatchSource - Operations: accept sessionIds and lookbackDays, compute time range, pass to API - CLI: add -d/--lookback-days and -s/--session-ids options for batch-evaluation - TUI: add lookback days input and session discovery/multi-select to batch eval wizard - Fix nit: remove "API accepts" mention from evaluator help text * fix: send sessionIds or sessionFilterConfig, not both API requires either sessionIds OR sessionFilterConfig in cloudWatchSource.sessionInput. When sessionIds are provided (from session picker), skip the time filter. * feat: batch eval ground truth + config bundle & status fixes (#62) * feat: ground truth support for batch evaluation (CLI + TUI) - Add sessionMetadata types (assertions, expected trajectory, turns) - Add --ground-truth/-g CLI flag to load ground truth from JSON file - Add TUI ground truth step with skip/file/inline options - Use PathInput file picker for ground truth file selection - Fix evaluator ARN resolution (extract short name from full ARN) - Merge session IDs from ground truth metadata with wizard selection - Fix PathInput Enter key to auto-select highlighted dropdown item - Fix TUI wizard Esc to go back one step instead of exiting * fix: display config bundles and ab tests in CLI status command The status command was computing statuses for config-bundle and ab-test resources but never rendering them. Also fixes empty target name display. * fix: resolve stale config bundle IDs with API fallback When config bundles are recreated, deployed-state.json retains old bundle IDs that no longer exist. Both CLI and TUI now fall back to the list API when the deployed-state bundleId returns a 404, fixing the "no differences found" issue in config bundle diff. * fix: batch eval uses deployed region from aws-targets Instead of relying solely on detectRegion() (which defaults to us-east-1), batch evaluation now reads the region from aws-targets.json first. This ensures it runs against the region where the agent is actually deployed. Applies to both CLI and TUI flows. * feat: add placeholder and format hint for config bundle components input Shows expected JSON format (component ARN → configuration map) as a hint above the inline input and as placeholder text. Also adds placeholder for file path input. * fix: improve config-bundle --help to clarify bundle name usage Clarifies that --bundle takes the bundle name from agentcore.json (not the AWS bundle ID), and that --from/--to version IDs come from the versions subcommand output. * fix: pass branchName to getConfigurationBundle API calls The GetConfigurationBundle API now requires a branchName query parameter. Without it, the API returns 404 "Configuration bundle branch not found". This was causing config-bundle versions/diff commands to fail even after the stale ID fallback resolved the correct bundle ID. * fix: show log file path on failure and saved results path on success For batch eval and recommendation commands (both CLI and TUI): - On failure: show the log file path so users can debug - On success: show the saved results file path (recommendation CLI was missing this; batch eval TUI now shows it too) * feat: AB test HTTP gateway, trace delivery, and TUI improvements (#63) * feat: add stop commands and Esc-to-stop for batch eval & recommendation (#65) * fix: always show gateway step in AB test wizard instead of silently skipping (#67) When no existing gateways are available, the wizard silently skipped the gateway step, leaving customers unaware that a gateway would be auto-created. Now the step always shows with "Create new HTTP gateway" as the only option, making the auto-creation explicit and visible. * feat: bundle Python SDK wheel into CLI for offline install - Add src/assets/wheels/ directory for bundled Python wheels - Add --find-links to uv pip install args (async + sync) to prefer local wheels over PyPI when present - Add wheel copy step to bundle.mjs for redundant safety - Include bedrock_agentcore-1.6.0 wheel from feat/evo_main branch When the CLI is installed from a bundle tarball, agentcore deploy will automatically use the bundled SDK wheel instead of fetching from PyPI, enabling fully offline preview distributions. * Fix __dirname in ESM bundle, update versions for evo-private-beta - Add __dirname/__filename shims to esbuild banner so bundled ESM CLI can resolve paths correctly (fixes ReferenceError at runtime) - Fix BUNDLED_WHEELS_DIR path: ../assets/wheels from dist/cli/ instead of ../../assets/wheels which resolved to the wrong directory - Bump CLI version to 0.8.0-evo-private-beta to avoid collision with public 0.8.0 release - Replace Python SDK wheel with 1.6.0.dev20260410 built from feat/evo_main branch of bedrock-agentcore-sdk-python-private * chore: update CLI version to 0.8.0-evo-private-beta-20260410 * fix: invocation URL in AB test views and listHttpGatewayTargets parsing (#68) * fix: show full invocation URL in AB test views and fix listHttpGatewayTargets parsing - Remove gateway URL from agent status view (only show for AB tests) - Rename 'Gateway URL' to 'Invocation URL' across AB test views - Fix listHttpGatewayTargets to parse 'items' key from API response - Fetch target name in AB test detail screen for full invocation URL - Add unit tests for listHttpGatewayTargets * chore: remove overwritten test file * fix: resolve config bundle ARN and recommendation improvements (#71) * fix: resolve config bundle ARN from deployed state for recommendations The recommendation CLI command was passing the human-readable bundle name (e.g. "MyBundle") as the bundleArn field in the API request instead of the actual ARN. This caused a 400 validation error from the API. Now resolves the bundle ARN from deployed state when using --bundle-name, and passes ARN values through directly (for TUI which already stores ARNs). Also includes: - Config bundle support in deploy preflight and CDK templates - JSONPath dot notation for config bundle field resolution - Session ID auto-fetch for all recommendation types (not just tool-desc) - Conditional tool explanation display (hide when empty) - CLI progress output for non-TUI recommendation command - Request IDs included in API error messages for debugging - Lint fixes in preflight.ts (type safety, deduplicated imports) * feat: auto-apply recommendation results to config bundles When recommendations use a config bundle as input source, automatically apply the results back to agentcore.json. CLI auto-applies after display, TUI offers an "Apply to config bundle" action. Supports both system prompt and tool description recommendations via JSONPath resolution. * refactor: sync config bundle from server instead of local apply The server automatically creates a new config bundle version when a recommendation uses a config bundle as input. Instead of locally parsing JSONPath and writing recommended text, fetch the server-created version via GetConfigurationBundleVersion and sync local agentcore.json + deployed state to match. * fix: hide empty explanation in recommendation results Check for non-empty explanation before displaying in both CLI and TUI. The API sometimes returns an empty string for explanation fields. * fix: remove hardcoded branchName 'main' in config bundle resolution resolveBundleByName was passing branchName: 'main' to GetConfigurationBundle, causing 404 errors for bundles on other branches (mainline, experiment, etc.). Omit branchName so the API returns the latest version regardless of branch. * feat: simplify add config bundle TUI with component picker Replace raw JSON/ARN input with a guided flow: select component type (runtime/gateway), pick from deployed resources, enter just the config values. Supports adding multiple components via "Add another?" loop. * fix: remove incorrect CW unsupported disclaimer for tool-desc recommendations CloudWatch trace source is supported for tool description recommendations. Remove the misleading note that said otherwise. * feat: support CloudWatch logs trace source for tool-desc recommendations * feat: add create config bundle option in AB test variant picker * feat: add batch eval history screen and duration hint - New BatchEvalHistoryScreen in evals hub for viewing past batch eval results - Add "Run Batch Evaluation" and "Batch Eval History" to evals hub - Add "This may take a few minutes..." message in CLI and TUI during batch eval * fix: config bundle Esc on componentType goes back instead of addAnother Pressing Esc on the component type step was incorrectly navigating to the "add another?" step when components already existed. Now it always goes to the previous step (description) as expected. * fix: resolve {{agent:name}} placeholder in config bundle component keys The resolveComponentKey function handled {{runtime:name}} and {{gateway:name}} but not {{agent:name}}, which is the documented… * fix: remove dead preflight patch and use proper teardown for evo resources (#1072) - Preflight: remove config bundle type patching entirely — the CDK schema does not have a type field, so neither the on-disk write (original) nor the in-memory patch was needed - Teardown: delegate to deleteOrphanedABTests and deleteOrphanedHttpGateways instead of raw deleteABTest/deleteHttpGatewayWithTargets — reuses stop/poll/delete/role-cleanup logic. Correct ordering: AB tests first (they create rules on gateways), then gateways, then config bundles. Closes #1070 * fix: remove dead preflight patch, proper teardown, optional evo schema fields (#1073) - Preflight: remove config bundle type patching entirely — the CDK schema does not have a type field, so the patch was dead code - Teardown: delegate to deleteOrphanedABTests and deleteOrphanedHttpGateways instead of raw API calls — reuses stop/poll/delete/role-cleanup logic. Correct ordering: AB tests first (they create rules on gateways), then gateways, then config bundles. - Schema: change configBundles, abTests, httpGateways from .default([]) to .optional() so they don't appear in agentcore.json unless the user opts into those preview features. All access sites updated with ?? [] or ??= guards. Closes #1070 * fix: revert .optional() to .default([]) and strip empty evo arrays on write (#1074) The .optional() change from #1073 breaks Zod JSON Schema generation ("Undefined cannot be represented in JSON Schema"). Revert to .default([]) so schema generation and parsing work, but strip empty configBundles/abTests/httpGateways in writeProjectSpec before writing to disk so preview features don't pollute agentcore.json. * fix: remove unnecessary non-null assertions after .default([]) revert (#1075) * chore: bump version to 0.13.0 (#1076) Co-authored-by: github-actions[bot] * docs: remove CrewAI from supported frameworks (#1059) CrewAI is not implemented as a framework option in the CLI — it does not exist in SDKFrameworkSchema, has no template assets, and is not in the SDK_MODEL_PROVIDER_MATRIX. Remove it from README and docs/frameworks.md to match the actual CLI capabilities. Constraint: Only frameworks in src/schema/constants.ts SDKFrameworkSchema are valid Rejected: Mark CrewAI as "BYO only" | no special BYO treatment exists for it Confidence: high Scope-risk: narrow * chore(deps): override glob to ^13 to silence install deprecation warning (#1008) Co-authored-by: minorun365 * fix: address formatting failure in docs (#1080) * test: remove http-gateway-targets e2e test (#1090) HTTP gateways are created through the AB test flow (add ab-test --mode target-based), not via agentcore add gateway. The standalone test was using MCP gateway commands against placeholder URLs that caused AWS::BedrockAgentCore::GatewayTarget CREATE_FAILED errors. HTTP gateway coverage is provided by ab-test-target-based.test.ts. Co-authored-by: Claude Sonnet 4.6 (1M context) * fix: sync e2e IAM policy and fix run eval flag (#1092) * fix: align E2E batch eval and recommendation tests with current API (#1093) * fix: use correct resourceType for config bundle in E2E status test (#1094) The status command outputs resourceType as 'config-bundle' (hyphenated) but the test was checking for 'configBundle' (camelCase). * docs: clarify integration vs e2e test boundaries and add e2e README (#1111) * docs: clarify integration vs e2e test boundaries and add e2e README - Rewrite integ-tests/README.md to accurately describe what integration tests do (local file/stdout assertions, no AWS required) — the old README described e2e behavior (CloudFormation, credentials, costs) - Add e2e-tests/README.md documenting the AWS boundary, prerequisites, createE2ESuite() pattern, key patterns, and cleanup requirements - Update docs/TESTING.md to add an E2E Tests section with a link to the new README, fix the stale integration tests section that incorrectly listed AWS credentials as a prerequisite, and add link to integ README * chore: fix prettier formatting in docs and READMEs * feat: add archive command for batch evaluations and recommendations (#1112) * feat: add archive command for batch evaluations and recommendations. Introduces a new top-level archive command with two subcommands: archive batch-evaluation and archive recommendation. Each calls the corresponding service delete API and removes the matching local .cli/ history file. * fix: prefix HTTP gateway names with project name to prevent cross-project collisions (#1105) * test: collapse schema enumeration tests and remove duplicates (#1087) * test: collapse schema enumeration tests and remove duplicate help text tests Collapse it.each per-value enumeration patterns into consolidated tests across 9 schema test files. Each enum value was tested individually (e.g., 4 separate 'accepts SEMANTIC', 'accepts SUMMARIZATION' tests) -- now represented by 1-2 tests with representative values. Remove deploy/dev --help unit tests (covered by integ-tests/help.test.ts) and 5 it.todo placeholders from credential-ops.test.ts. Schema tests: ~506 -> ~415. All cross-field validation, superRefine, boundary, and discriminated union tests preserved. * fix: address review comments — restore help tests and add .options assertions - Restore deploy --help and dev --help flag-level assertions (integ smoke test only checks exit code and "Usage:", not specific flags) - Restore --invoke negative regression guard in dev.test.ts - Replace representative-sample enum tests with .options assertions for AgentCoreRegionSchema and GatewayTargetTypeSchema (catches accidental removal of enum values) * fix: set iamRoleFallback to true for lambda gateway targets (#1086) * fix: set iamRoleFallback to true for lambda gateway targets Keep TARGET_TYPE_AUTH_CONFIG in sync with @aws/agentcore-cdk — lambda targets need GATEWAY_IAM_ROLE just like lambdaFunctionArn targets. Related: https://github.com/aws/agentcore-cli/issues/1005 * chore: trigger CI re-run * style: fix prettier table alignment in docs/frameworks.md * fix: correct AB test execution role IAM policy and promote stability (#1120) * fix: add GetEvaluator permission to AB test execution role The AB test API validates evaluator ARNs server-side using the execution role. The auto-created role policy was missing bedrock-agentcore:GetEvaluator, causing a 400 "Access denied when validating evaluator" error on every deploy that included a target-based AB test with a custom evaluator. * fix: correct status assertion in target-based AB test e2e HTTP gateways are not surfaced as top-level resources in agentcore status — they are only used internally to build AB test invocation URLs. The test was asserting on resourceType 'http-gateway' which never appears; fix it to assert on the 'ab-test' resource instead. * fix: add invocationUrl to status resource type cast in e2e test The resource cast was missing invocationUrl, causing a TypeScript error when asserting the AB test's gateway invocation URL was present. * fix: improve promote error message and add local e2e run script - Include stdout in promote failure message so the JSON error is visible - Add scripts/run-e2e-local.sh to replicate the GitHub Actions e2e workflow locally * fix: retry promote stop on 409 UPDATING status When promote is called immediately after resume, the AB test may still be in UPDATING state and reject the STOPPED transition with a 409. Retry up to 6 times with a 10s delay to wait for the transition to complete. * fix: wait for AB test to leave UPDATING state before promoting Poll getABTest until executionStatus is no longer UPDATING before attempting to stop. The service rejects updates with 409 while a state transition is in progress (e.g. after resume). * fix: wait for RUNNING before stopping AB test in promote Poll getABTest until executionStatus === 'RUNNING' before issuing the STOPPED transition. The service 409s if the test is still UPDATING (e.g. transitioning from a prior resume). * fix: resolve evaluator ARNs transitively from AB test online eval configs Previously GetEvaluator was scoped to all project evaluators. Now it's scoped to only the evaluators referenced by the specific online eval configs this AB test uses, handling all three cases: - Custom evaluators: looked up from deployedResources.evaluators - Builtin.* evaluators: ARN constructed from the builtin ID - External ARN references: used as-is * fix: align AB test execution role policy with official docs Replace the hand-rolled per-resource policy with the canonical policy from https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/ab-testing-prereqs.html: - Trust policy: add SourceAccount + SourceArn conditions - Permissions: single AgentCoreResources statement scoped to arn:aws:bedrock-agentcore:*:${accountId}:* with ResourceAccount condition - Add missing actions: GetGatewayTarget, ListGatewayTargets, ListConfigurationBundleVersions - CloudWatch logs scoped to account via PrincipalAccount pattern - Remove per-evaluator/per-resource ARN tracking (no longer needed) * test: update IAM role unit tests to match new policy structure * fix: throw error if AB test never reaches RUNNING before promote * test: add unit tests for promote polling logic and extract to promote-utils Extract waitForRunningThenStop into promote-utils.ts so it can be unit tested without pulling in React/ink. Add 4 tests covering: immediate RUNNING, polling until RUNNING, timeout throws, and error message content. * fix: split DescribeLogGroups into wildcard resource statement * test: verify AB test reaches RUNNING status after deploy in both e2e suites * fix: remove console.log statements that leaked ARNs and fix bundle ARN/version format in config-bundle e2e test * test: remove second deploy and RUNNING check from config-bundle e2e (follow-up with real bundles) --------- Co-authored-by: Local E2E * chore: bump version to 0.13.1 (#1129) Co-authored-by: github-actions[bot] * chore(deps-dev): bump secretlint from 12.3.1 to 13.0.0 Bumps [secretlint](https://github.com/secretlint/secretlint) from 12.3.1 to 13.0.0. - [Release notes](https://github.com/secretlint/secretlint/releases) - [Commits](https://github.com/secretlint/secretlint/compare/v12.3.1...v13.0.0) --- updated-dependencies: - dependency-name: secretlint dependency-version: 13.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * fix: pin a2a-sdk below 1.0 to prevent breaking changes The a2a-python SDK recently released 1.0 with breaking protocol changes (0.3 spec -> 1.0 spec). Our A2A templates use 0.3-era APIs (a2a.server, a2a.types, a2a.utils) which are incompatible with the 1.0 release. Add an upper bound (< 1.0.0) to all three A2A template pyproject.toml files so pip resolves to 0.3.26 (latest compatible) instead of 1.0.x. * chore(deps): bump @opentelemetry/exporter-metrics-otlp-http (#1141) Bumps [@opentelemetry/exporter-metrics-otlp-http](https://github.com/open-telemetry/opentelemetry-js) from 0.214.0 to 0.217.0. - [Release notes](https://github.com/open-telemetry/opentelemetry-js/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-js/compare/experimental/v0.214.0...experimental/v0.217.0) --- updated-dependencies: - dependency-name: "@opentelemetry/exporter-metrics-otlp-http" dependency-version: 0.217.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump react from 19.2.5 to 19.2.6 (#1136) Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.5 to 19.2.6. - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react) --- updated-dependencies: - dependency-name: react dependency-version: 19.2.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(deploy): pass stack selection to diff and deploy for --target filtering (#980) (#1148) * feat(harness): add verdict prefix to reviewer comments (#1153) * feat(harness): add verdict prefix requirement to reviewer system prompt The PR reviewer agent now must begin every comment with APPROVED, APPROVED WITH MINOR COMMENTS, or REQUESTING CHANGES so outcomes are immediately visible and parseable. * feat(harness): use gh pr review for formal review verdicts Instead of plain comments with text prefixes, the reviewer agent now submits formal GitHub PR reviews via `gh pr review` with --approve, --request-changes, or --comment. This integrates with branch protection rules and makes the review status machine-readable. * refactor(harness): move review verdict to review.md as one-liner Keep system.md as pure workspace context. The review task prompt now ends with a single directive to submit a formal PR review rather than a plain comment. * Update PR review submission instructions Clarified instructions for submitting PR reviews. * Add labels to Slack issue notification payload (#1162) * fix: apply prettier formatting to review.md (#1167) * ci: add workflow to create issues on CI failure (#1174) * ci: add workflow to create issues on CI failure * style: fix prettier formatting * ci: use AUTOMATION_ACCOUNT_PAT_TOKEN for issue creation (#1176) * fix: use search API with listForRepo fallback for issue dedup (#1180) * chore(deps-dev): bump hono from 4.12.14 to 4.12.18 (#1152) Bumps [hono](https://github.com/honojs/hono) from 4.12.14 to 4.12.18. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.14...v4.12.18) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.18 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: resolve high-severity npm audit vulnerabilities (#1184) * fix: resolve high-severity npm audit vulnerabilities Run npm audit fix to address: - fast-xml-builder: attribute value quote bypass (high) - fast-uri: path traversal via percent-encoded dot segments (high, prod dep) - uuid: missing buffer bounds check (moderate) Remaining moderate vulnerabilities are in @aws-sdk transitive deps (fast-xml-parser < 5.7.0) which require upstream SDK updates. * fix: remove stale fast-xml-parser and @aws-sdk/xml-builder overrides Both override conditions are now met by upstream: - @aws-sdk/xml-builder@3.972.22 pins fast-xml-parser@5.7.2 - @aws-sdk/core@3.974.8 requires @aws-sdk/xml-builder ^3.972.22 Removing the overrides allows the natural resolution to pick up the patched versions, clearing the remaining moderate advisory (GHSA-gh4j-gqv2-49f6, fast-xml-parser < 5.7.0). security:audit now reports 0 vulnerabilities (--omit=dev). * test: remove unnecessary mocks, use real filesystem (#1156) * test(resolve-ui-dist-dir): replace fs mock with real temp directory * test(BaseRenderer): remove APP_DIR and fs mocks, use real temp directories * test(get-trace): remove fs mock, verify file output with real filesystem * test(update-notifier): remove fs and compareVersions mocks, use real filesystem - Replace fs/promises mock with real temp directory via GLOBAL_CONFIG_DIR - Remove compareVersions mock, let real pure function run - Refactor source to use existing GLOBAL_CONFIG_DIR (supports AGENTCORE_CONFIG_DIR env var) - Only mock fetchLatestVersion (network) and PACKAGE_VERSION (constant) * test: address review feedback on mock removal changes - Remove redundant CACHE_DIR alias, use tmpDir directly - Add force:true to afterAll rmSync for robustness - Wrap chmod test in try/finally to prevent leaked read-only state - Remove vacuous conditional assertion in resolve-ui-dist-dir - Switch get-trace to beforeEach/afterEach for test isolation consistency * review.md: specify I/O boundary examples (network, AWS SDK, HTTP) * fix: handle CloudFormation throttling in import gateway polling (#1185) * fix: handle CloudFormation throttling in import gateway polling Adds a shared poll() utility with throttle-aware retry and migrates phase2-import.ts to use it. Previously, Rate exceeded errors from CloudFormation during concurrent e2e tests would crash the import operation. Now throttle errors are retried on the next poll iteration. Fixes: import-gateway e2e test failures under parallel execution * fix: preserve lastError as cause in poll errors, add domain error wrapping Addresses PR review feedback: - PollExhaustedError and PollTimeoutError now include the last error as `cause` for debuggability (e.g., shows 'Rate exceeded' when throttling exhausts retries) - phase2-import.ts wraps poll errors with operation-specific messages ('Timed out waiting for change set creation') preserving original error as cause - Fixed misleading message when maxConsecutiveErrors triggers (now reports actual attempt count) - Added 3 tests verifying cause propagation * test: scope fake timers to beforeEach/afterEach to prevent leaks * docs: add bedrock:CountTokens to IAM policy examples (#1181) Strands SDK uses bedrock:CountTokens to estimate token usage before model calls, enabling proactive context window management. Users running Strands-based agents need this permission in their IAM policies. Co-authored-by: Local E2E * fix: resolve target-based AB test target name mismatch (#1188) * docs: split TESTING.md into focused per-type docs (#1192) * docs: split TESTING.md into per-type docs with manual testing requirement Break the monolithic TESTING.md into focused files under docs/testing/ (unit, integration, TUI, browser, e2e, manual) and add a requirement that every change must be manually tested before submitting. * fix: address review comments on testing docs - e2e-tests.md: use correct `npm run test:e2e` command, add AWS creds prerequisite - manual-testing.md: fix tarball glob to `aws-agentcore-*.tgz` * style: fix prettier formatting in testing docs * feat: wire telemetry into all remove.* commands (#1069) * fix: widen TUI panel to prevent text truncation (#1191) (#1193) * fix: widen TUI panel to prevent text truncation in advanced settings The panel was hard-capped at 60 characters, causing option descriptions to be cut off with ellipses on wide terminals. Raised the cap to 100 and switched MultiSelectList from truncate to wrap for graceful handling on narrow terminals. Closes #1191 * docs: add screenshot for PR * chore: remove screenshot from repo * fix: default Panel to fullWidth so it fills the terminal Addresses reviewer feedback — panels now expand to fill available terminal width by default instead of capping at a fixed column count. * fix: revert wrap change, keep truncate for multi-select list Since panels now default to fullWidth, the truncation issue is solved by the wider panel itself. Keeping truncate avoids visual ambiguity on narrow terminals where wrapped text could overlap adjacent rows. * test: replace obsolete contentWidth test with fullWidth assertion * fix: remove content width cap, use full terminal width everywhere * fix: replace manual width strings with Ink layout components Remove buildLogo and manual '─'.repeat(contentWidth) dividers. Use Ink's Box with borderStyle and width="100%" instead, letting the layout engine handle fitting within parent padding automatically. * docs: add telemetry instrumentation guide (#1197) * chore: replace PAT tokens with GitHub App token (#1198) Replace secrets.PAT_TOKEN and secrets.AUTOMATION_ACCOUNT_PAT_TOKEN with short-lived tokens generated by the agentcore-devx-automation GitHub App (ID: 3637953) via actions/create-github-app-token@v1. This improves security by using ephemeral tokens scoped to the installation rather than long-lived personal access tokens. Requires adding repo variable APP_ID=3637953 and repo secret APP_PRIVATE_KEY with the app's RSA private key. * fix: add batch eval, recommendation, and CloudWatch Logs write permissions to docs (#1113) * feat: instrument telemetry for create command (CLI + TUI) (#1202) Add telemetry recording to the create command in both CLI and TUI paths: - CLI: wrap handleCreateCLI with runCliCommand to emit CreateAttrs on success/failure - TUI: wrap useCreateFlow's run() with withCommandRunTelemetry - Add telemetry assertions to existing integration tests (frameworks + edge cases) * chore: replace all github.token/GITHUB_TOKEN with GitHub App token Replaces all occurrences of \${{ github.token }} and \${{ secrets.GITHUB_TOKEN }} across .github/workflows/ with a per-job GitHub App token generated via actions/create-github-app-token@v1 using vars.APP_ID and secrets.APP_PRIVATE_KEY. * fix: bump versions to resolve security audit failure * revert: keep pr-title.yml using GITHUB_TOKEN (read-only access sufficient) * feat(evaluator): add kmsKeyArn support for custom evaluator (#994) * feat(evaluator): Add kmsKeyArn support for custom evaluator * fix: sync package-lock.json with package.json The lock file was out of sync after dependency bumps on main were merged, causing npm ci to fail in CI. * fix: revert unrelated dep bumps and fix formatting Reverts @opentelemetry/exporter-metrics-otlp-http ^0.217.0 back to ^0.214.0 and secretlint ^13.0.0 back to ^12.2.0 — these were accidentally included in the feature commit from unmerged dependabot PRs and introduce high-severity protobufjs vulnerabilities. Restores fast-xml-parser and @aws-sdk/xml-builder overrides that were also inadvertently removed. Fixes Prettier formatting on agentcore-project.ts import lines. * fix: sync package-lock.json with updated dependencies --------- Co-authored-by: notgitika * refactor: unify result types with discriminated Result union (#1125) * refactor: unify result types with discriminated Result union Introduce a shared Result type (inspired by Rust's Result) that replaces ad-hoc { success: boolean; error?: string } patterns across the codebase. Key changes: - Add src/lib/types.ts with Result discriminated union type - Add toError() helper in src/cli/errors.ts for catch blocks - Migrate all command, operation, and primitive result types to Result - Error field is now Error (not string) on the failure branch - Data fields only exist on the success branch (proper narrowing) - Update all consumers to narrow before accessing branch-specific fields - Update test assertions to match new Error objects and add narrowing * docs: update AGENTS.md and telemetry README to reflect Result type * feat: record command attrs on telemetry failure via fallbackAttrs (#1204) * feat: record command attrs on telemetry failure via fallbackAttrs Add optional fallbackAttrs parameter to client.withCommandRun so command-specific attributes are recorded even when the callback throws. - client.ts: accept fallbackAttrs, use on failure instead of {} - client.ts: run resilientParse on all non-empty attrs (not just success) - cli-command-run.ts: withCommandRunTelemetry passes attrs as fallbackAttrs - cli-command-run.ts: runCliCommand accepts optional knownAttrs param - command.tsx: extract knownAttrs upfront, pass to runCliCommand - client.test.ts: add unit tests for fallbackAttrs behavior - create-edge-cases.test.ts: assert attrs present on failure entry * chore: rebase onto mainline * fix: sync-preview pushes directly on clean merge, PRs only on conflict (#1078) * fix: sync-preview workflow restores version instead of ignoring files Instead of keeping preview's entire package.json/package-lock.json (which discards new deps, scripts, etc. from main), accept main's content and surgically restore only the version field to preview's value after merge. * fix: push directly to preview on clean merge via GitHub App bypass Use agentcore-devx-automation app token to bypass branch protection and push directly when the merge is clean (or only version conflicts). Only creates a PR when there are real conflicts in other files. * chore: use app-slug instead of app-id for token generation * fix: address review feedback on sync-preview workflow - Pass PREVIEW_VERSION via env var instead of string interpolation in node -e scripts (safer against special chars) - Make git add of package-lock.json conditional on file existence to match the earlier -f guard - Replace loose title search for dedup with headRefName prefix filter to avoid false positives from unrelated PRs - Clarify why package.json/package-lock.json are special-cased (preview carries a different version string that needs preserving) * fix: restore preview-owned files after sync merge Adds a step to restore schemas/agentcore.schema.v1.json and CHANGELOG.md to preview's versions after merging main. These files are auto-generated during preview releases — schema-check CI rejects direct modifications to schemas/, and CHANGELOG.md tracks preview releases separately. * fix: use app-id instead of app-slug for GitHub App token Aligns with the pattern in PR #1210 and ci-failure-issue.yml. * feat: instrument telemetry for deploy command (CLI + TUI) (#1206) --------- Signed-off-by: dependabot[bot] Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Co-authored-by: Trirmadura J Ariyawansa Co-authored-by: Avi Alpert <131792194+avi-alpert@users.noreply.github.com> Co-authored-by: Gitika <53349492+notgitika@users.noreply.github.com> Co-authored-by: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Imran Ismail Co-authored-by: Claude Opus 4.6 Co-authored-by: Jesse Turner Co-authored-by: Avi Alpert Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Álvaro <159990212+alvarog2491@users.noreply.github.com> Co-authored-by: Yasuhiro Horiuchi Co-authored-by: kashinoki38 <21358299+kashinoki38@users.noreply.github.com> Co-authored-by: padmak30 Co-authored-by: padmak30 Co-authored-by: Minoru Onda(みのるん) <74597894+minorun365@users.noreply.github.com> Co-authored-by: minorun365 Co-authored-by: Local E2E Co-authored-by: Harrison Weinstock Co-authored-by: aws-aditya21 Co-authored-by: notgitika --- .github/workflows/ci-failure-issue.yml | 9 +- .github/workflows/cleanup-pr-tarballs.yml | 8 +- .github/workflows/pr-tarball.yml | 8 +- .../workflows/release-main-and-preview.yml | 18 +- .github/workflows/release.yml | 9 +- .github/workflows/strands-command.yml | 8 +- .github/workflows/sync-from-public.yml | 22 +- .github/workflows/sync-preview.yml | 146 ++- docs/PERMISSIONS.md | 35 +- docs/policies/iam-policy-user.json | 23 +- integ-tests/create-edge-cases.test.ts | 24 +- integ-tests/create-frameworks.test.ts | 16 +- package-lock.json | 1146 +++++++++-------- package.json | 14 +- src/cli/AGENTS.md | 9 +- src/cli/__tests__/errors.test.ts | 33 +- .../aws/__tests__/transaction-search.test.ts | 21 +- src/cli/aws/agentcore-control.ts | 2 + src/cli/aws/index.ts | 2 +- src/cli/aws/transaction-search.ts | 48 +- src/cli/commands/add/types.ts | 32 - src/cli/commands/create/action.ts | 42 +- src/cli/commands/create/command.tsx | 190 +-- src/cli/commands/create/types.ts | 8 +- .../commands/deploy/__tests__/utils.test.ts | 54 + src/cli/commands/deploy/actions.ts | 33 +- src/cli/commands/deploy/command.tsx | 111 +- src/cli/commands/deploy/types.ts | 15 +- src/cli/commands/deploy/utils.ts | 33 + src/cli/commands/dev/browser-mode.ts | 12 +- src/cli/commands/eval/command.tsx | 5 +- .../import/__tests__/idempotency.test.ts | 26 +- .../import/__tests__/import-evaluator.test.ts | 43 + .../__tests__/import-gateway-flow.test.ts | 49 +- .../import/__tests__/import-no-deploy.test.ts | 16 +- .../__tests__/import-runtime-handler.test.ts | 29 +- .../import/__tests__/jwt-authorizer.test.ts | 13 + .../__tests__/phase-failure-rollback.test.ts | 36 +- src/cli/commands/import/actions.ts | 23 +- src/cli/commands/import/command.ts | 2 +- src/cli/commands/import/import-evaluator.ts | 3 +- src/cli/commands/import/import-gateway.ts | 7 +- src/cli/commands/import/import-memory.ts | 2 +- src/cli/commands/import/import-online-eval.ts | 2 +- src/cli/commands/import/import-runtime.ts | 2 +- src/cli/commands/import/import-utils.ts | 2 +- src/cli/commands/import/resource-import.ts | 5 +- src/cli/commands/import/types.ts | 17 +- src/cli/commands/invoke/action.ts | 109 +- src/cli/commands/invoke/command.tsx | 9 +- src/cli/commands/invoke/types.ts | 10 +- .../commands/logs/__tests__/action.test.ts | 12 +- src/cli/commands/logs/action.ts | 21 +- src/cli/commands/logs/command.tsx | 4 +- src/cli/commands/pause/command.tsx | 6 +- src/cli/commands/remove/command.tsx | 8 +- src/cli/commands/remove/types.ts | 8 +- src/cli/commands/run/command.tsx | 21 +- .../commands/status/__tests__/action.test.ts | 57 +- src/cli/commands/status/action.ts | 29 +- src/cli/commands/status/command.tsx | 17 +- src/cli/commands/traces/action.ts | 24 +- src/cli/commands/traces/command.tsx | 4 +- .../validate/__tests__/action.test.ts | 32 +- src/cli/commands/validate/action.ts | 16 +- src/cli/commands/validate/command.tsx | 2 +- src/cli/commands/validate/index.ts | 2 +- src/cli/errors.ts | 10 - .../agent/generate/write-agent-to-project.ts | 3 +- src/cli/operations/agent/import/index.ts | 5 +- .../post-deploy-observability.test.ts | 7 +- src/cli/operations/deploy/index.ts | 3 +- .../deploy/post-deploy-observability.ts | 10 +- src/cli/operations/deploy/teardown.ts | 8 +- .../eval/__tests__/list-eval-runs.test.ts | 25 +- .../eval/__tests__/logs-eval.test.ts | 13 +- .../eval/__tests__/pause-resume.test.ts | 37 +- .../eval/__tests__/run-eval.test.ts | 76 +- src/cli/operations/eval/index.ts | 1 - src/cli/operations/eval/list-eval-runs.ts | 11 +- src/cli/operations/eval/logs-eval.ts | 15 +- src/cli/operations/eval/pause-resume.ts | 13 +- .../operations/eval/run-batch-evaluation.ts | 19 +- src/cli/operations/eval/run-eval.ts | 24 +- .../mcp/__tests__/create-mcp-utils.test.ts | 16 +- .../memory/__tests__/create-memory.test.ts | 16 +- .../operations/memory/list-memory-records.ts | 16 +- .../memory/retrieve-memory-records.ts | 16 +- .../__tests__/apply-to-bundle.test.ts | 15 +- .../__tests__/recommendation-storage.test.ts | 4 +- .../__tests__/run-recommendation.test.ts | 68 +- .../recommendation/apply-to-bundle.ts | 19 +- .../recommendation/recommendation-storage.ts | 6 +- .../recommendation/run-recommendation.ts | 38 +- src/cli/operations/recommendation/types.ts | 13 +- .../remove/__tests__/remove-agent-ops.test.ts | 28 +- .../__tests__/remove-gateway-ops.test.ts | 14 +- .../__tests__/remove-identity-ops.test.ts | 22 +- .../__tests__/remove-memory-ops.test.ts | 14 +- .../remove/remove-gateway-target.ts | 17 +- src/cli/operations/remove/types.ts | 5 - .../traces/__tests__/get-trace.test.ts | 47 +- .../traces/__tests__/list-traces.test.ts | 24 +- src/cli/operations/traces/get-trace.ts | 39 +- src/cli/operations/traces/insights-query.ts | 20 +- src/cli/operations/traces/list-traces.ts | 2 +- src/cli/operations/traces/types.ts | 22 +- src/cli/primitives/ABTestPrimitive.ts | 25 +- src/cli/primitives/AgentPrimitive.tsx | 36 +- src/cli/primitives/BasePrimitive.ts | 9 +- src/cli/primitives/ConfigBundlePrimitive.ts | 17 +- src/cli/primitives/CredentialPrimitive.tsx | 34 +- src/cli/primitives/EvaluatorPrimitive.ts | 34 +- src/cli/primitives/GatewayPrimitive.ts | 20 +- src/cli/primitives/GatewayTargetPrimitive.ts | 37 +- src/cli/primitives/MemoryPrimitive.tsx | 18 +- .../primitives/OnlineEvalConfigPrimitive.ts | 17 +- src/cli/primitives/PolicyEnginePrimitive.ts | 22 +- src/cli/primitives/PolicyPrimitive.ts | 54 +- .../primitives/RuntimeEndpointPrimitive.ts | 38 +- .../__tests__/ABTestPrimitive.test.ts | 23 +- .../__tests__/BasePrimitive.test.ts | 5 +- .../__tests__/EvaluatorPrimitive.test.ts | 31 +- .../__tests__/GatewayPrimitive.test.ts | 8 + .../OnlineEvalConfigPrimitive.test.ts | 21 +- .../RuntimeEndpointPrimitive.test.ts | 49 +- src/cli/primitives/index.ts | 2 +- src/cli/primitives/types.ts | 17 +- src/cli/telemetry/README.md | 25 +- src/cli/telemetry/__tests__/client.test.ts | 49 + src/cli/telemetry/cli-command-run.ts | 26 +- src/cli/telemetry/client.ts | 8 +- src/cli/telemetry/error-classification.ts | 5 + .../schemas/__tests__/command-run.test.ts | 6 +- src/cli/telemetry/schemas/command-run.ts | 4 +- .../tui/hooks/__tests__/useRemove.test.tsx | 2 +- src/cli/tui/hooks/useCreateABTest.ts | 4 +- src/cli/tui/hooks/useCreateConfigBundle.ts | 2 +- src/cli/tui/hooks/useCreateEvaluator.ts | 4 +- src/cli/tui/hooks/useCreateMcp.ts | 2 +- src/cli/tui/hooks/useCreateMemory.ts | 2 +- src/cli/tui/hooks/useCreateOnlineEval.ts | 2 +- src/cli/tui/hooks/useRemove.ts | 13 +- src/cli/tui/screens/agent/useAddAgent.ts | 26 +- src/cli/tui/screens/create/useCreateFlow.ts | 50 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 26 +- src/cli/tui/screens/eval/EvalScreen.tsx | 2 +- .../screens/evaluator/AddEvaluatorScreen.tsx | 23 +- src/cli/tui/screens/evaluator/types.ts | 3 + .../evaluator/useAddEvaluatorWizard.ts | 21 +- .../tui/screens/identity/useCreateIdentity.ts | 2 +- src/cli/tui/screens/import/ImportFlow.tsx | 2 +- .../screens/import/ImportProgressScreen.tsx | 8 +- .../online-eval/OnlineEvalDashboard.tsx | 2 +- src/cli/tui/screens/policy/AddPolicyFlow.tsx | 4 +- .../recommendation/RecommendationFlow.tsx | 19 +- src/cli/tui/screens/remove/RemoveFlow.tsx | 48 +- src/cli/tui/screens/remove/useRemoveFlow.ts | 6 +- .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 4 +- src/cli/tui/screens/run-eval/RunEvalFlow.tsx | 10 +- .../AddRuntimeEndpointFlow.tsx | 2 +- src/cli/tui/screens/status/useStatusFlow.ts | 2 +- src/lib/errors/index.ts | 1 + src/lib/errors/types.ts | 94 ++ src/lib/index.ts | 2 + src/lib/result.test.ts | 19 + src/lib/result.ts | 30 + .../schemas/io/__tests__/config-io.test.ts | 2 +- .../io/__tests__/path-resolver.test.ts | 2 +- src/lib/schemas/io/config-io.ts | 3 +- src/lib/schemas/io/index.ts | 1 - src/lib/schemas/io/path-resolver.ts | 12 +- src/schema/schemas/agentcore-project.ts | 16 +- src/schema/schemas/primitives/evaluator.ts | 22 + 174 files changed, 3029 insertions(+), 1671 deletions(-) create mode 100644 src/cli/commands/deploy/__tests__/utils.test.ts create mode 100644 src/cli/commands/deploy/utils.ts create mode 100644 src/lib/errors/types.ts create mode 100644 src/lib/result.test.ts create mode 100644 src/lib/result.ts diff --git a/.github/workflows/ci-failure-issue.yml b/.github/workflows/ci-failure-issue.yml index 2114154ba..0cbb430a1 100644 --- a/.github/workflows/ci-failure-issue.yml +++ b/.github/workflows/ci-failure-issue.yml @@ -19,9 +19,16 @@ jobs: permissions: issues: write steps: + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: actions/github-script@v9 with: - github-token: ${{ secrets.AUTOMATION_ACCOUNT_PAT_TOKEN }} + github-token: ${{ steps.app-token.outputs.token }} script: | try { const workflowName = context.payload.workflow_run.name; diff --git a/.github/workflows/cleanup-pr-tarballs.yml b/.github/workflows/cleanup-pr-tarballs.yml index 1892f3a17..10a35be2c 100644 --- a/.github/workflows/cleanup-pr-tarballs.yml +++ b/.github/workflows/cleanup-pr-tarballs.yml @@ -14,10 +14,16 @@ jobs: runs-on: ubuntu-latest steps: + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - uses: actions/checkout@v6 - name: Delete PR tarball releases older than 7 days env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | CUTOFF=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-7d +%Y-%m-%dT%H:%M:%SZ) diff --git a/.github/workflows/pr-tarball.yml b/.github/workflows/pr-tarball.yml index 3c5c5c522..a217daacc 100644 --- a/.github/workflows/pr-tarball.yml +++ b/.github/workflows/pr-tarball.yml @@ -50,10 +50,16 @@ jobs: run: | TARBALL_NAME=$(ls *.tgz | head -1 | xargs basename) echo "name=$TARBALL_NAME" >> $GITHUB_OUTPUT + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Create or update PR release id: release env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ github.event.pull_request.number }} TARBALL_NAME: ${{ steps.tarball.outputs.name }} run: | diff --git a/.github/workflows/release-main-and-preview.yml b/.github/workflows/release-main-and-preview.yml index b3cfb5ee2..c726518f2 100644 --- a/.github/workflows/release-main-and-preview.yml +++ b/.github/workflows/release-main-and-preview.yml @@ -135,9 +135,16 @@ jobs: - name: Update snapshots run: npm run test:update-snapshots + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Create release branch and PR env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} NEW_VERSION: ${{ steps.bump.outputs.version }} run: | BRANCH_NAME="release/v$NEW_VERSION" @@ -219,9 +226,16 @@ jobs: - name: Update snapshots run: npm run test:update-snapshots + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Create release branch and PR env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} NEW_VERSION: ${{ steps.bump.outputs.version }} run: | BRANCH_NAME="release/v$NEW_VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be8e6122f..f14766188 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,9 +147,16 @@ jobs: exit 1 fi + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Create Pull Request env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} NEW_VERSION: ${{ steps.bump.outputs.version }} GITHUB_REF: ${{ github.ref }} GITHUB_ACTOR: ${{ github.actor }} diff --git a/.github/workflows/strands-command.yml b/.github/workflows/strands-command.yml index a4e20a16b..dcf768206 100644 --- a/.github/workflows/strands-command.yml +++ b/.github/workflows/strands-command.yml @@ -94,6 +94,12 @@ jobs: }; await processInputs(context, github, core, inputs); + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Run Strands Agent uses: ./.github/actions/strands-action with: @@ -104,7 +110,7 @@ jobs: tools: 'strands_tools:shell,retrieve' aws_role_arn: ${{ secrets.AWS_ROLE_ARN }} aws_region: 'us-west-2' - pat_token: ${{ secrets.GITHUB_TOKEN }} + pat_token: ${{ steps.app-token.outputs.token }} env: SESSION_ID: ${{ steps.process-inputs.outputs.session_id }} S3_SESSION_BUCKET: ${{ secrets.AGENT_SESSIONS_BUCKET }} diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml index 143b09b34..ef35927d9 100644 --- a/.github/workflows/sync-from-public.yml +++ b/.github/workflows/sync-from-public.yml @@ -13,10 +13,17 @@ jobs: sync: runs-on: ubuntu-latest steps: + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - name: Configure Git run: | @@ -101,15 +108,22 @@ jobs: --head "$conflict_branch" || echo "⚠️ Failed to create PR" fi env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} sync-preview: runs-on: ubuntu-latest steps: + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} - name: Configure Git run: | @@ -194,4 +208,4 @@ jobs: --head "$conflict_branch" || echo "⚠️ Failed to create PR" fi env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/sync-preview.yml b/.github/workflows/sync-preview.yml index 61abcbc86..b4f0fa1b0 100644 --- a/.github/workflows/sync-preview.yml +++ b/.github/workflows/sync-preview.yml @@ -17,19 +17,27 @@ jobs: name: Merge main into preview runs-on: ubuntu-latest steps: + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Checkout preview uses: actions/checkout@v6 with: ref: preview fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} - name: Configure git run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - - name: Merge main into preview - id: merge + - name: Check if sync needed + id: check run: | git fetch origin main MAIN_SHA=$(git rev-parse origin/main) @@ -37,73 +45,133 @@ jobs: if [[ "$MAIN_SHA" == "$MERGE_BASE" ]]; then echo "✅ preview already contains all of main" - echo "status=up-to-date" >> $GITHUB_OUTPUT - exit 0 + echo "needed=false" >> $GITHUB_OUTPUT + else + echo "needed=true" >> $GITHUB_OUTPUT fi - echo "ℹ️ Merging main into preview..." + - name: Skip if already synced + if: steps.check.outputs.needed == 'false' + run: echo "Nothing to sync." + + - name: Merge main into preview + if: steps.check.outputs.needed == 'true' + id: merge + run: | + # Save preview's version before merge so we can restore it after + PREVIEW_VERSION=$(node -p "require('./package.json').version") + echo "preview_version=$PREVIEW_VERSION" >> $GITHUB_OUTPUT if git merge origin/main --no-edit -m "chore: merge main into preview"; then - git push origin preview - echo "✅ main merged into preview and pushed" - echo "status=merged" >> $GITHUB_OUTPUT + echo "status=clean" >> $GITHUB_OUTPUT else - git merge --abort - echo "status=conflict" >> $GITHUB_OUTPUT + # preview carries a higher version string than main (e.g. 1.0.0-preview.X vs 0.13.X). + # This means package.json/package-lock.json almost always conflict on the version field. + # Accept main's content here; the version is restored in the next step. + for f in package.json package-lock.json; do + if git diff --name-only --diff-filter=U | grep -qx "$f"; then + git checkout --theirs "$f" + git add "$f" + echo " ↳ resolved $f conflict (accepted main, will restore version)" + fi + done + + # Check if all conflicts are now resolved + if [[ -z "$(git diff --name-only --diff-filter=U)" ]]; then + git commit --no-edit -m "chore: merge main into preview" + echo "status=clean" >> $GITHUB_OUTPUT + else + echo "status=conflict" >> $GITHUB_OUTPUT + fi fi - - name: Get original commit author - if: steps.merge.outputs.status == 'conflict' - id: author + - name: Restore preview-owned files + if: steps.merge.outputs.status == 'clean' run: | - AUTHOR=$(git log origin/main -1 --format='%an') - GH_USER=$(git log origin/main -1 --format='%ae' | grep -oP '.*(?=@users\.noreply\.github\.com)' || echo "") - if [[ -z "$GH_USER" ]]; then - # Try to get GitHub username from the commit - GH_USER=$(gh api "/repos/${{ github.repository }}/commits/$(git rev-parse origin/main)" --jq '.author.login // empty' 2>/dev/null || echo "") + # These files are auto-generated during preview releases and must not + # be overwritten by main's versions (schema-check CI will reject changes + # to schemas/, and CHANGELOG.md tracks preview releases separately). + PREVIEW_HEAD=$(git rev-parse HEAD^1) + for f in schemas/agentcore.schema.v1.json CHANGELOG.md; do + if git show "$PREVIEW_HEAD:$f" > /dev/null 2>&1; then + git show "$PREVIEW_HEAD:$f" > "$f" + git add "$f" + echo " ↳ restored preview's $f" + fi + done + if ! git diff --cached --quiet; then + git commit -m "chore: restore preview-owned files (schema, changelog)" fi - echo "name=$AUTHOR" >> $GITHUB_OUTPUT - echo "gh_user=$GH_USER" >> $GITHUB_OUTPUT - env: - GH_TOKEN: ${{ github.token }} + + - name: Restore preview version and push + if: steps.merge.outputs.status == 'clean' + run: | + PREVIEW_VERSION="${{ steps.merge.outputs.preview_version }}" + CURRENT_VERSION=$(node -p "require('./package.json').version") + + if [[ "$CURRENT_VERSION" != "$PREVIEW_VERSION" ]]; then + PREVIEW_VERSION="$PREVIEW_VERSION" node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = process.env.PREVIEW_VERSION; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + if [[ -f package-lock.json ]]; then + PREVIEW_VERSION="$PREVIEW_VERSION" node -e " + const fs = require('fs'); + const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8')); + lock.version = process.env.PREVIEW_VERSION; + if (lock.packages && lock.packages['']) { + lock.packages[''].version = process.env.PREVIEW_VERSION; + } + fs.writeFileSync('package-lock.json', JSON.stringify(lock, null, 2) + '\n'); + " + fi + git add package.json + [[ -f package-lock.json ]] && git add package-lock.json + git commit -m "chore: restore preview version ($PREVIEW_VERSION)" + fi + + git push origin HEAD:preview + echo "✅ main merged into preview and pushed" - name: Create PR for conflict resolution if: steps.merge.outputs.status == 'conflict' env: - GH_TOKEN: ${{ github.token }} - AUTHOR_NAME: ${{ steps.author.outputs.name }} - AUTHOR_GH: ${{ steps.author.outputs.gh_user }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - BRANCH="sync-preview/merge-main-$(date +%Y%m%d-%H%M%S)" - - # Check if there's already an open sync PR - EXISTING=$(gh pr list --base preview --search "sync-preview: merge main into preview" --state open --json number --jq 'length') - if [[ "$EXISTING" != "0" ]]; then + # Check if there's already an open sync PR (match by branch prefix, not title search) + COUNT=$(gh pr list --base preview --state open --json headRefName \ + --jq '[.[] | select(.headRefName | startswith("sync-preview/"))] | length') + if [[ "$COUNT" != "0" ]]; then echo "ℹ️ Sync PR already open — skipping duplicate." exit 0 fi - # Create a branch from preview with the conflict markers + # Abort the failed merge and redo on a branch for the PR + git merge --abort + + BRANCH="sync-preview/merge-main-$(date +%Y%m%d-%H%M%S)" git checkout -b "$BRANCH" - git merge origin/main --no-edit -m "chore: merge main into preview" || true + git merge origin/main --no-edit -m "chore: merge main into preview (conflicts need resolution)" || true git add -A git commit --no-edit -m "chore: merge main into preview (conflicts need resolution)" || true git push origin "$BRANCH" - # Build mention string + GH_USER=$(gh api "/repos/${{ github.repository }}/commits/$(git rev-parse origin/main)" --jq '.author.login // empty' 2>/dev/null || echo "") MENTION="" - if [[ -n "$AUTHOR_GH" ]]; then - MENTION="cc @${AUTHOR_GH}" + if [[ -n "$GH_USER" ]]; then + MENTION="cc @${GH_USER}" fi gh pr create \ --base preview \ --head "$BRANCH" \ - --title "sync-preview: merge main into preview" \ + --title "sync-preview: merge main into preview (conflicts)" \ --body "$(cat < { let testDir: string; + const telemetry = createTelemetryHelper(); + beforeAll(async () => { testDir = join(tmpdir(), `agentcore-integ-edge-${randomUUID()}`); await mkdir(testDir, { recursive: true }); }); afterAll(async () => { + telemetry.destroy(); await rm(testDir, { recursive: true, force: true }); }); describe('reserved names', () => { it('rejects reserved name "Test"', async () => { - const result = await runCLI(['create', '--name', 'Test', '--json'], testDir); + const result = await runCLI(['create', '--name', 'Test', '--json'], testDir, { env: telemetry.env }); expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); @@ -30,6 +34,13 @@ describe.skipIf(!prereqs.npm || !prereqs.git)('integration: create edge cases', json.error.toLowerCase().includes('reserved') || json.error.toLowerCase().includes('conflict'), `Error should mention reserved/conflict: ${json.error}` ).toBeTruthy(); + + telemetry.assertMetricEmitted({ + command: 'create', + exit_reason: 'failure', + language: 'python', + has_agent: 'true', + }); }); it('rejects reserved name "bedrock"', async () => { @@ -121,12 +132,21 @@ describe.skipIf(!prereqs.npm || !prereqs.git)('integration: create edge cases', describe('flag interactions', () => { it('--defaults creates project with default settings', async () => { const name = `Def${Date.now().toString().slice(-6)}`; - const result = await runCLI(['create', '--name', name, '--defaults', '--json'], testDir); + const result = await runCLI(['create', '--name', name, '--defaults', '--json'], testDir, { env: telemetry.env }); expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); const json = JSON.parse(result.stdout); expect(json.success).toBe(true); expect(json.projectPath).toBeTruthy(); + + telemetry.assertMetricEmitted({ + command: 'create', + exit_reason: 'success', + language: 'python', + framework: 'strands', + model_provider: 'bedrock', + has_agent: 'true', + }); }); it('--dry-run shows what would be created without writing files', async () => { diff --git a/integ-tests/create-frameworks.test.ts b/integ-tests/create-frameworks.test.ts index 82bbc0871..1be05d237 100644 --- a/integ-tests/create-frameworks.test.ts +++ b/integ-tests/create-frameworks.test.ts @@ -1,4 +1,5 @@ import { exists, prereqs, readProjectConfig, runCLI } from '../src/test-utils/index.js'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -8,12 +9,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe.skipIf(!prereqs.npm || !prereqs.git)('integration: create with different frameworks', () => { let testDir: string; + const telemetry = createTelemetryHelper(); + beforeAll(async () => { testDir = join(tmpdir(), `agentcore-integ-frameworks-${randomUUID()}`); await mkdir(testDir, { recursive: true }); }); afterAll(async () => { + telemetry.destroy(); await rm(testDir, { recursive: true, force: true }); }); @@ -34,7 +38,8 @@ describe.skipIf(!prereqs.npm || !prereqs.git)('integration: create with differen 'none', '--json', ], - testDir + testDir, + { env: telemetry.env } ); expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); @@ -60,6 +65,15 @@ describe.skipIf(!prereqs.npm || !prereqs.git)('integration: create with differen expect(agents).toBeDefined(); expect(agents.length).toBe(1); expect(agents[0]!.name).toBe(agentName); + + telemetry.assertMetricEmitted({ + command: 'create', + exit_reason: 'success', + language: 'python', + framework: 'langchain_langgraph', + model_provider: 'bedrock', + has_agent: 'true', + }); }); it('creates GoogleADK project with Gemini provider', async () => { diff --git a/package-lock.json b/package-lock.json index 69d83fe40..f65623d1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@aws-sdk/client-bedrock": "^3.1012.0", "@aws-sdk/client-bedrock-agent": "^3.1012.0", "@aws-sdk/client-bedrock-agentcore": "^3.1020.0", - "@aws-sdk/client-bedrock-agentcore-control": "^3.1020.0", + "@aws-sdk/client-bedrock-agentcore-control": "^3.1039.0", "@aws-sdk/client-bedrock-runtime": "^3.893.0", "@aws-sdk/client-cloudformation": "^3.893.0", "@aws-sdk/client-cloudwatch-logs": "^3.893.0", @@ -27,7 +27,7 @@ "@aws/agent-inspector": "0.3.0", "@commander-js/extra-typings": "^14.0.0", "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-metrics-otlp-http": "^0.217.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.215.0", "@opentelemetry/otlp-transformer": "^0.213.0", "@opentelemetry/resources": "^2.6.1", "@opentelemetry/sdk-metrics": "^2.6.1", @@ -77,7 +77,7 @@ "lint-staged": "^16.2.7", "node-pty": "^1.1.0", "prettier": "^3.7.4", - "secretlint": "^13.0.0", + "secretlint": "^12.2.0", "tsx": "^4.21.0", "typescript": "^5", "typescript-eslint": "^8.50.1", @@ -242,9 +242,9 @@ } }, "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "53.19.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.19.0.tgz", - "integrity": "sha512-pHtEeuiPwYu4vWJhqTqBCvOWy2w4BjYFW5W8L5lH5AzhKLLG92o+Ck0oLcKUkB0Ucl4ff49WL9K7sa7g35ionw==", + "version": "53.18.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.18.0.tgz", + "integrity": "sha512-/fa6rOpokkfa5tVIdhsaexQq5MVVTSsZSD1Tu45YcrdyGRusGrM9RlPMCPrwvMS1UfdVFBhcgO9dl9ODWAWOeQ==", "bundleDependencies": [ "jsonschema", "semver" @@ -377,14 +377,14 @@ } }, "node_modules/@aws-cdk/toolkit-lib": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/toolkit-lib/-/toolkit-lib-1.25.0.tgz", - "integrity": "sha512-yTxQLcI7T8wfUAhWw8vO7dG//x0WL786AA5u4i/yRtSVSJd+0frHX6gD+njSv41OaNLhwP6EVYkFz9wTyCt64A==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/toolkit-lib/-/toolkit-lib-1.24.0.tgz", + "integrity": "sha512-tgtH0CJ8/N/CpT1/ebOBfUpxdAMSRsP9LTAjWfa+E0clX4Vuvx0w1J1bGYwtvKY9nQUbFIO4QfgNEHz8hVlMUA==", "license": "Apache-2.0", "dependencies": { "@aws-cdk/cdk-assets-lib": "^1", "@aws-cdk/cloud-assembly-api": "2.2.2", - "@aws-cdk/cloud-assembly-schema": ">=53.19.0", + "@aws-cdk/cloud-assembly-schema": ">=53.18.0", "@aws-cdk/cloudformation-diff": "^2", "@aws-cdk/cx-api": "^2", "@aws-sdk/client-appsync": "^3", @@ -415,7 +415,7 @@ "@smithy/util-retry": "^4", "@smithy/util-waiter": "^4", "archiver": "^7.0.1", - "cdk-from-cfn": "^0.297.0", + "cdk-from-cfn": "^0.295.0", "chalk": "^4", "chokidar": "^4", "fast-deep-equal": "^3.1.3", @@ -687,24 +687,24 @@ } }, "node_modules/@aws-sdk/client-application-signals": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-application-signals/-/client-application-signals-3.1038.0.tgz", - "integrity": "sha512-kUBaXESTxZO+JiMQIFU/Wv0Lop+KtuJP0xOv00dL/4a0RdcqNdAwbH7RnxXaL5z7toDLCfg9j93f0IQEdBLbXA==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-application-signals/-/client-application-signals-3.1037.0.tgz", + "integrity": "sha512-xnGVyIWU1SXNSnnARvU3U3sic0QWH0wek/X3WpXFCpOm0NBzbTablWiAszNDU9RCvg9KUDm6Wdp0T4jnodXhEg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -712,7 +712,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -728,7 +728,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -788,25 +788,25 @@ } }, "node_modules/@aws-sdk/client-bedrock": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1038.0.tgz", - "integrity": "sha512-WY99Vodg7V4hxLQn7HOLawXHeVYv8Ys16Xx3CPpu8L7+1spvO/i4uykzTXH6GkojdAqNO2CSclhk31lb85nSWg==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1037.0.tgz", + "integrity": "sha512-XGuJ86vuuEsqp0Gq8fMCSMd/VNCwqTvKwFT99SU2OOLyNp31ChZ+LdIckJZl/A3jpUyZYpXjn7IxP/N/6UFiZA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/token-providers": "3.1038.0", + "@aws-sdk/token-providers": "3.1037.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -814,7 +814,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -830,7 +830,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -839,24 +839,24 @@ } }, "node_modules/@aws-sdk/client-bedrock-agent": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agent/-/client-bedrock-agent-3.1038.0.tgz", - "integrity": "sha512-cwOkwVhYs+GeW7SzNS/pmgkHZ/SAyv4wIkz62vew6sM6R+XgqqBbtEqDrf+FSsprFtx75edbduRg8boJt0veSg==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agent/-/client-bedrock-agent-3.1037.0.tgz", + "integrity": "sha512-8Uc3zdwfxmjMrXb4qP69bByL054R+jWNaW3/Hq93E1jnag8vZz8YhJlz1x6jr1oXOoOLVErc1L/g8x0sMFZuRA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -864,7 +864,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -880,7 +880,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -889,24 +889,24 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1038.0.tgz", - "integrity": "sha512-HHsWj3AWsBnvihV0oUolOlnPxXum75kTdQHuYMX6kko4ek2iQWOe1WxUQG4TwOS7HUivU3Zf6CNti/K6wEkc7A==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1037.0.tgz", + "integrity": "sha512-8WmZulMmFnCWFuX2rDBoZdebCMmmrAi1VABsLgm4O73w3+s7tcON1YgspG9gTevuVRtOVdk1B6TLw2Mo8NBHSQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", @@ -917,7 +917,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -933,7 +933,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -943,24 +943,24 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore-control": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1038.0.tgz", - "integrity": "sha512-S+BtFwV7C5yUwjMZwExp0hAmdMCXLA2iG6KPvoS4zv8T2bMk1itrWfE4opqSZ1lgwDmnazcG7mmfAAUBFXvKXw==", + "version": "3.1039.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1039.0.tgz", + "integrity": "sha512-YoJ1rYHikWbsBuTyjADxtYzD+PXmyR2X6NVWkX4AqUtZT1YJC1HW+BsFABlXfItsEOjss3kpsjQHnlMbiCBtig==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.7", + "@aws-sdk/credential-provider-node": "^3.972.38", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.37", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.23", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -968,7 +968,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -984,7 +984,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.3.0", "tslib": "^2.6.2" @@ -994,28 +994,28 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1038.0.tgz", - "integrity": "sha512-oGiqs9v9WzPOdv7PDdm9iPibHgrbDvCDyNg43wFZn2PiiEUisFM+xUP2CRMsj41SmwZPhohmZkXiUu1+MghbAQ==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1037.0.tgz", + "integrity": "sha512-Evla4DUdBf1pQpQa7pbfquj7jRaRktkI0qGoWBJBXWB9wQISzJ8OEI4sHugk/W6SF47C7hMP/o3Z/XBrfnejCw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/middleware-websocket": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/token-providers": "3.1038.0", + "@aws-sdk/token-providers": "3.1037.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", @@ -1026,7 +1026,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1042,7 +1042,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -1051,6 +1051,42 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": { + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1037.0.tgz", + "integrity": "sha512-csxa484KboWLs3f8jFQ5v9RwH8FVf0fQ+SO3GSXyu4Jtinhh4qXmOWLSVX30RBpB933dZaKGHGEXzEEY88NqRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock/node_modules/@aws-sdk/token-providers": { + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1037.0.tgz", + "integrity": "sha512-csxa484KboWLs3f8jFQ5v9RwH8FVf0fQ+SO3GSXyu4Jtinhh4qXmOWLSVX30RBpB933dZaKGHGEXzEEY88NqRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/nested-clients": "^3.997.3", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-cloudcontrol": { "version": "3.1036.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudcontrol/-/client-cloudcontrol-3.1036.0.tgz", @@ -1103,24 +1139,24 @@ } }, "node_modules/@aws-sdk/client-cloudformation": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1038.0.tgz", - "integrity": "sha512-5c8u4magoWj8uAhiKoBY0/FXqCRqw3g05RXiQzUdNbeZDhfnv2b/r0aITVLlVEW/zfQUGi/WJfaV4vuffp6k1A==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1037.0.tgz", + "integrity": "sha512-nLSwtmayv7tjjp6t8Lc20xZCeA+XJ5UzXvauQCnO3aRZVAxrgarQntZjS+eWlRYGRqLBjXSre4xL7XwUlObb2A==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -1128,7 +1164,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1144,9 +1180,9 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.3.0", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -1154,24 +1190,24 @@ } }, "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1038.0.tgz", - "integrity": "sha512-OfdF+xZiiHWR5JL9rsc8jjlk1fNxnX2uS2iWWBM9vncVSXdhPAMb/rFNeHLkIx9IfmyiNi7kXUTAKgIUkVN0+w==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1037.0.tgz", + "integrity": "sha512-W0TRDfyBikNR+DzOTBgBLT4TqVHCAasqx2Xu4G4PfTRCansUtEJRydq0CEVOpHlMfme4Va89O/r9sp/VoDsKRg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", @@ -1182,7 +1218,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1198,7 +1234,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1257,24 +1293,24 @@ } }, "node_modules/@aws-sdk/client-cognito-identity": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1038.0.tgz", - "integrity": "sha512-tTSXUZXzydM0VUoxcrM4YrhhQfFgepfpbRLEq460650rFAC8NsGhGQ6Ixo7UPV6TKEyI/jQcCnQVi4RVM4SkAg==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1037.0.tgz", + "integrity": "sha512-/BQAyz98JRQFg3E8de3fGGydIYnsFRd6Cla4+zkviOe641fLCG0ZkPIk9D22HSi8qy9XKx+zk6ed2PcLO8uuPw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -1282,7 +1318,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1298,7 +1334,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1307,25 +1343,25 @@ } }, "node_modules/@aws-sdk/client-cognito-identity-provider": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1038.0.tgz", - "integrity": "sha512-E3/2sei5wiUvS+ZdlvQ93SXU2C9zdqq1mJqxik6B8GHtSnP2T4J97Hdu46FM6GK/HZJ+iFZFeIlM0B+P3uH2QQ==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1037.0.tgz", + "integrity": "sha512-w0HuaMNtzcj6bErBX8/TVGbOz0a8JNCzPHLMq2u/ll4uuxl9Xut0njuy7vyY0/pYCuNE+no4uq+yHwn8U3ptgw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -1333,7 +1369,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1349,7 +1385,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1719,24 +1755,24 @@ } }, "node_modules/@aws-sdk/client-resource-groups-tagging-api": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-resource-groups-tagging-api/-/client-resource-groups-tagging-api-3.1038.0.tgz", - "integrity": "sha512-jEjVlhcAb4j658XKfxBPyyrbXJKmfg2bR3Xokqy5lhvZA80t6p47jeQ4s5yXIsaNzxnefJ8SuqrVft2KBHbDrQ==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-resource-groups-tagging-api/-/client-resource-groups-tagging-api-3.1037.0.tgz", + "integrity": "sha512-1+sv7vbSSRqVMBTvgFPFopXZ/SGdz3jP9aIHM8eOIuuZGYSmuqiXkNd7Ag5yxIQqEDO8sASevCqscEqN0f4OUw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -1744,7 +1780,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1760,7 +1796,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1821,32 +1857,32 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1038.0.tgz", - "integrity": "sha512-k60qm50bWkaqNfCJe1z28WaqgpztE0wbWVMZw6ZJcTOGfrWFhsJeLCEqtkH8w00iEozKx9GQwdQXz4G0sMGdKA==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1037.0.tgz", + "integrity": "sha512-DBmA1jAW8ST6C4srBxeL1/RLIir/d8WOm4s4mi59mGp6mBktHM59Kwb7GuURaCO60cotuce5zr0sKpMLPcBQyA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-bucket-endpoint": "^3.972.10", "@aws-sdk/middleware-expect-continue": "^3.972.10", - "@aws-sdk/middleware-flexible-checksums": "^3.974.14", + "@aws-sdk/middleware-flexible-checksums": "^3.974.13", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-location-constraint": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-sdk-s3": "^3.972.35", + "@aws-sdk/middleware-sdk-s3": "^3.972.34", "@aws-sdk/middleware-ssec": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.22", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/eventstream-serde-browser": "^4.2.14", @@ -1860,7 +1896,7 @@ "@smithy/md5-js": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -1876,10 +1912,10 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.3.0", + "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" }, "engines": { @@ -2038,25 +2074,25 @@ } }, "node_modules/@aws-sdk/client-sts": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1038.0.tgz", - "integrity": "sha512-lboBAXDIr+ot5a357mQgaAwgMMYZW7EwO216LTASUHV3UN4YgqskrEcwsDV9765KH9wUDGxFt8rClS4ixaOgiA==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.1037.0.tgz", + "integrity": "sha512-Ye+BEvy1Fd/JtqfF1T9PiodIU52/Cd9sP4oBLnj8QQEyYRUcYG1OQ2xIFXF/gzAAMjfVN8HqGJo9LxdmScxZAQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.22", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -2064,7 +2100,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -2080,7 +2116,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2089,24 +2125,24 @@ } }, "node_modules/@aws-sdk/client-xray": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-xray/-/client-xray-3.1038.0.tgz", - "integrity": "sha512-wPO92YHLQDbaHog82RFCmrGmMl5bdF6SOcejBr/RGcORsL4t6RXvaVP9sF73txgOTDRgUQh/EO1uy/JmTC9Dgg==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-xray/-/client-xray-3.1037.0.tgz", + "integrity": "sha512-cnic610qpFrbR3gNw0pFi6EFrkDhDJOHlOnQzpdjBQ38O3QXwkON6Kco8IyTs7TlyOr7HRmHnBiEYVDZrLVC0w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-node": "^3.972.36", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.35", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.21", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -2114,7 +2150,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.5", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -2130,7 +2166,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.4", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2139,9 +2175,9 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", - "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", + "version": "3.974.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.7.tgz", + "integrity": "sha512-YhRC90ofz5oolTJZlA8voU/oUrCB2azi8Usx51k8hhB5LpWbYQMMXKUqSqkoL0Cru+RQJgWTHpAfEDDIwfUhJw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", @@ -2163,6 +2199,40 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/core/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz", + "integrity": "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core/node_modules/fast-xml-parser": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", + "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/@aws-sdk/crc64-nvme": { "version": "3.972.7", "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz", @@ -2177,12 +2247,12 @@ } }, "node_modules/@aws-sdk/credential-provider-cognito-identity": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.29.tgz", - "integrity": "sha512-fklwtMw+9+1TRNa7KOCaaE9P9ubN6PdKCVlviX/vPRNtnMGIivAFrWcYsAcyw+sHPPioiSCSOHKKAhtOkO6IGg==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.28.tgz", + "integrity": "sha512-UXhc4FfxbfNaIqycDnIZ+W8CMAoCtcJJfZkq+cWSUwQRN0V0d0uAoN2qCFyKZip8inlHeKJmNQsPliKKcElP8Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", @@ -2193,12 +2263,12 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.32", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.32.tgz", - "integrity": "sha512-7vA4GHg8NSmQxquJHSBcSM3RgB4ZaaRi6u4+zGFKOmOH6aqlgr2Sda46clkZDYzlirgfY96w15Zj0jh6PT48ng==", + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.33.tgz", + "integrity": "sha512-bJV7eViSJV6GSuuN+VIdNVPdwPsNSf75BiC2v5alPrjR/OCcqgKwSZInKbDFz9mNeizldsyf67jt6YSIiv53Cw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.7", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", @@ -2209,12 +2279,12 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.34.tgz", - "integrity": "sha512-vBrhWujFCLp1u8ptJRWYlipMutzPptb8pDQ00rKVH9q67T7rGd3VTWIj63aKrlLuY6qSsw1Rt5F/D/7wnNgryA==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.35.tgz", + "integrity": "sha512-x/BQGEIdq0oI+4WxLjKmnQvT7CnF9r8ezdGt7wXwxb7ckHXQz0Zmgxt8v3Ne0JaT3R5YefmuybHX6E8EnsDXyA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.7", "@aws-sdk/types": "^3.973.8", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", @@ -2230,19 +2300,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.36.tgz", - "integrity": "sha512-FBHyCmV8EB0gUvh1d+CZm87zt2PrdC7OyWexLRoH3I5zWSOUGa+9t58Y5jbxRfwUp3AWpHAFvKY6YzgR845sVA==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.37.tgz", + "integrity": "sha512-eUTpmWfd/BKsq9medhCRcu+GRAhFP2Zrn7/2jKDHHOOjCkhrMoTp/t4cEthqFoG7gE0VGp5wUxrXTdvBCmSmJg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-env": "^3.972.32", - "@aws-sdk/credential-provider-http": "^3.972.34", - "@aws-sdk/credential-provider-login": "^3.972.36", - "@aws-sdk/credential-provider-process": "^3.972.32", - "@aws-sdk/credential-provider-sso": "^3.972.36", - "@aws-sdk/credential-provider-web-identity": "^3.972.36", - "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/core": "^3.974.7", + "@aws-sdk/credential-provider-env": "^3.972.33", + "@aws-sdk/credential-provider-http": "^3.972.35", + "@aws-sdk/credential-provider-login": "^3.972.37", + "@aws-sdk/credential-provider-process": "^3.972.33", + "@aws-sdk/credential-provider-sso": "^3.972.37", + "@aws-sdk/credential-provider-web-identity": "^3.972.37", + "@aws-sdk/nested-clients": "^3.997.5", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", @@ -2255,13 +2325,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.36.tgz", - "integrity": "sha512-IFap01lJKxQc0C/OHmZwZQr/cKq0DhrcmKedRrdnnl42D+P0SImnnnWQjv07uIPqpEdtqmkPXb9TiPYTU+prxQ==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.37.tgz", + "integrity": "sha512-Ty68y8ISSC+g5Q3D0K8uAaoINwvfaOslnNpsF/LgVUxyosYXHawcK2yV4HLXDVugiTTYLQfJfcw0ce5meAGkKw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/core": "^3.974.7", + "@aws-sdk/nested-clients": "^3.997.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", @@ -2274,17 +2344,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.37.tgz", - "integrity": "sha512-/WFixFAAiw8WpmjZcI0l4t3DerXLmVinOIfuotmRZnu2qmsFPoqqmstASz0z8bi1pGdFXzeLzf6bwucM3mZcUQ==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.38.tgz", + "integrity": "sha512-BQ9XYnBDVxR2HuV5huXYQYF/PZMTsY+EnwfGnCU2cA8Zw63XpkOtPY8WqiMIZMQCrKPQQEiFURS/o9CIolRLqg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.32", - "@aws-sdk/credential-provider-http": "^3.972.34", - "@aws-sdk/credential-provider-ini": "^3.972.36", - "@aws-sdk/credential-provider-process": "^3.972.32", - "@aws-sdk/credential-provider-sso": "^3.972.36", - "@aws-sdk/credential-provider-web-identity": "^3.972.36", + "@aws-sdk/credential-provider-env": "^3.972.33", + "@aws-sdk/credential-provider-http": "^3.972.35", + "@aws-sdk/credential-provider-ini": "^3.972.37", + "@aws-sdk/credential-provider-process": "^3.972.33", + "@aws-sdk/credential-provider-sso": "^3.972.37", + "@aws-sdk/credential-provider-web-identity": "^3.972.37", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", @@ -2297,12 +2367,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.32", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.32.tgz", - "integrity": "sha512-uZp4tlGbpczV8QxmtIwOpSkcyGtBRR8/T4BAumRKfAt1nwCig3FSCZvrKl6ARDIDVRYn5p2oRcAsfFR01EgMGA==", + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.33.tgz", + "integrity": "sha512-yfjGksI9WQbdMObb0VeLXqzTLI+a0qXLJT9gCDiv0+X/xjPpI3mTz6a5FibrhpuEKIe0gSgvs3MaoFZy5cx4WA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.7", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2314,14 +2384,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.36.tgz", - "integrity": "sha512-DsLr0UHMyKzRJKe2bjlwU8q1cfoXg8TIJKV/xwvnalAemiZLOZunFzj/whGnFDZIBVLdnbLiwv5SvRf1+CSwkg==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.37.tgz", + "integrity": "sha512-fpwE+20ntpp3i9Xb9vUuQfXLDKYHH+5I2V+ZG96SX1nBzrruhy10RXDgmN7t1etOz3c55stlA3TeQASUA451NQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", - "@aws-sdk/token-providers": "3.1038.0", + "@aws-sdk/core": "^3.974.7", + "@aws-sdk/nested-clients": "^3.997.5", + "@aws-sdk/token-providers": "3.1039.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2333,13 +2403,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.36.tgz", - "integrity": "sha512-uzrURO7frJhHQVVNR5zBJcCYeMYflmXcWBK1+MiBym2Dfjh6nXATrMixrmGZi+97Q7ETZ+y/4lUwAy0Nfnznjw==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.37.tgz", + "integrity": "sha512-aryawqyebf+3WhAFNHfF62rekFpYtVcVN7dQ89qnAWsa4n5hJst8qBG6gXC24WHtW7Nnhkf9ScYnjwo0Brn3bw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/core": "^3.974.7", + "@aws-sdk/nested-clients": "^3.997.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2351,23 +2421,23 @@ } }, "node_modules/@aws-sdk/credential-providers": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1038.0.tgz", - "integrity": "sha512-+B9BuRVPPKF0Q6msVS4vUGOsL4eUg7XYogikp56rUEQVoUVxn5ONyWlnNzsDMTv+BwuBgFo5N7gRZtEToAnSgg==", + "version": "3.1037.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1037.0.tgz", + "integrity": "sha512-TPPoQzfNkWltNgjJn3RRY1S8VXffDvv49xGGs9K0DrYS9LZCLLsoHmSmShx9HQusPc/4Oz23rfRWTolCU19PdQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-cognito-identity": "3.1038.0", - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/credential-provider-cognito-identity": "^3.972.29", - "@aws-sdk/credential-provider-env": "^3.972.32", - "@aws-sdk/credential-provider-http": "^3.972.34", - "@aws-sdk/credential-provider-ini": "^3.972.36", - "@aws-sdk/credential-provider-login": "^3.972.36", - "@aws-sdk/credential-provider-node": "^3.972.37", - "@aws-sdk/credential-provider-process": "^3.972.32", - "@aws-sdk/credential-provider-sso": "^3.972.36", - "@aws-sdk/credential-provider-web-identity": "^3.972.36", - "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/client-cognito-identity": "3.1037.0", + "@aws-sdk/core": "^3.974.5", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.28", + "@aws-sdk/credential-provider-env": "^3.972.31", + "@aws-sdk/credential-provider-http": "^3.972.33", + "@aws-sdk/credential-provider-ini": "^3.972.35", + "@aws-sdk/credential-provider-login": "^3.972.35", + "@aws-sdk/credential-provider-node": "^3.972.36", + "@aws-sdk/credential-provider-process": "^3.972.31", + "@aws-sdk/credential-provider-sso": "^3.972.35", + "@aws-sdk/credential-provider-web-identity": "^3.972.35", + "@aws-sdk/nested-clients": "^3.997.3", "@aws-sdk/types": "^3.973.8", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", @@ -2485,15 +2555,15 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.14.tgz", - "integrity": "sha512-mhTO3amGzYv/DQNbbqZo6UkHquBHlEEVRZwXmjeRqLmy1l9z3xCiFzglPL7n9JpVc2DZc9kjaraAn3JQrueZbw==", + "version": "3.974.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.13.tgz", + "integrity": "sha512-b6QUe2hQX9XsnCzp6mtzVaERhganDKeb8lmGL6pVhr7rRVH9S9keDFW7uKytuuqmcY5943FixoGqn/QL+sbUBA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.5", "@aws-sdk/crc64-nvme": "^3.972.7", "@aws-sdk/types": "^3.973.8", "@smithy/is-array-buffer": "^4.2.2", @@ -2602,12 +2672,12 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.35.tgz", - "integrity": "sha512-lLppaNTAz+wNgLdi4FtHzrlwrGF0ODTnBWHBaFg85SKs0eJ+M+tP5ifrA8f/0lNd+Ak3MC1NGC6RavV3ny4HTg==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.36.tgz", + "integrity": "sha512-YhPix+0x/MdQrb1Ug1GDKeS5fqylIy+naz800asX8II4jqfTk2KY2KhmmYCwZcky8YWtRQQwWCGdoqeAnip8Uw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.7", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.17", @@ -2641,18 +2711,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.36.tgz", - "integrity": "sha512-O2beToxguBvrZFFZ+fFgPbbae8MvyIBjQ6lImee4APHEXXNAD5ZJ2ayLF1mb7rsKw86TM81y5czg82bZncjSjg==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.37.tgz", + "integrity": "sha512-N1oNpdiLoVAWYD3WFBnUi3LlfoDA06ZHo4ozyjbsJNLvILzvt//0CnR8N+CZ0NWeYgVB/5V59ivixHCWCx2ALw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.7", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.6", "tslib": "^2.6.2" }, "engines": { @@ -2683,24 +2753,24 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.4.tgz", - "integrity": "sha512-4Sf+WY1lMJzXlw5MiyCMe/UzdILCwvuaHThbqMXS6dfh9gZy3No360I42RXquOI/ULUOhWy2HCyU0Fp20fQGPQ==", + "version": "3.997.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.5.tgz", + "integrity": "sha512-jGFr6DxtcMTmzOkG/a0jCZYv4BBDmeNYVeO+/memSoDkYCJu4Y58xviYmzwJfYyIVSts+X/BVjJm1uGBnwHEMg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.6", + "@aws-sdk/core": "^3.974.7", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.37", "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/signature-v4-multi-region": "^3.996.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.24", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.22", + "@aws-sdk/util-user-agent-node": "^3.973.23", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", @@ -2708,7 +2778,7 @@ "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", @@ -2724,7 +2794,7 @@ "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.5", + "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2749,12 +2819,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.23.tgz", - "integrity": "sha512-wBbys3Y53Ikly556vyADurKpYQHXS7Jjaskbz+Ga9PZCz7PB/9f3VdKbDlz7dqIzn+xwz7L/a6TR4iXcOi8IRw==", + "version": "3.996.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.24.tgz", + "integrity": "sha512-amP7tLikppN940wbBFISYqiuzVmpzMS9U3mcgtmVLjX4fdWI/SNCvrXv6ZxfVzTT4cT0rPKOLhFah2xLwzREWw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.35", + "@aws-sdk/middleware-sdk-s3": "^3.972.36", "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", @@ -2766,13 +2836,13 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1038.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1038.0.tgz", - "integrity": "sha512-Qniru+9oGGb/HNK/gGZWbV3jsD0k71ngE7qMQ/x6gYNYLd2EOwHCS6E2E6jfkaqO4i0d+nNKmfRy8bNcshKdGQ==", + "version": "3.1039.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1039.0.tgz", + "integrity": "sha512-NMSFL2HwkAOoCeLCQiqoOq5pT3vVbSjww2QZTuYgYknVwhhv125PSDzZIcL5EYnlxuPWjEOdauZK+FspkZDVdw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.6", - "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/core": "^3.974.7", + "@aws-sdk/nested-clients": "^3.997.5", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", @@ -2864,12 +2934,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.22.tgz", - "integrity": "sha512-YTYqTmOUrwbm1h99Ee4y/mVYpFRl0oSO/amtP5cc1BZZWdaAVWs9zj3TkyRHWvR9aI/ZS8m3mS6awXtYUlWyaw==", + "version": "3.973.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.23.tgz", + "integrity": "sha512-gGwq8L2Euw0aNG6Ey4EktiAo3fSCVoDy1CaBIthd+oeaKHPXUrNaApMewQ6La5Hv0lcznOtECZaNvYyc5LXXfA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/middleware-user-agent": "^3.972.37", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", @@ -2888,21 +2958,6 @@ } } }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", - "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws/agent-inspector": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@aws/agent-inspector/-/agent-inspector-0.3.0.tgz", @@ -4013,18 +4068,6 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4097,16 +4140,16 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.217.0.tgz", - "integrity": "sha512-1zkMzzhiNJdVmLxuwkltqWGw4fOOam47bqRxmuQNjyKJe/9NmY5cIrZ4kiQV7sVGxoOgT0ZvGUfLcjvtpC/b9Q==", + "version": "0.215.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.215.0.tgz", + "integrity": "sha512-FRydO5j7MWnXK9ghfykKxiSM8I5UeiicK/UNl3/mv86xoEKkb+LKz1I3WXgkuYVOQf22VNqbPO58s2W1mVWtEQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-exporter-base": "0.217.0", - "@opentelemetry/otlp-transformer": "0.217.0", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-metrics": "2.7.1" + "@opentelemetry/core": "2.7.0", + "@opentelemetry/otlp-exporter-base": "0.215.0", + "@opentelemetry/otlp-transformer": "0.215.0", + "@opentelemetry/resources": "2.7.0", + "@opentelemetry/sdk-metrics": "2.7.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4116,9 +4159,9 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/api-logs": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.217.0.tgz", - "integrity": "sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==", + "version": "0.215.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.215.0.tgz", + "integrity": "sha512-xrFlqhdhUyO8wSRn6DjE0145/HPWSJ5Nm0C7vWua6TdL/FSEAZvEyvdsa9CRXuxo9ebb7j/NEPhEcO62IJ0qUA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -4127,19 +4170,34 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/core": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", + "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.217.0.tgz", - "integrity": "sha512-MKK8UHKFUOGAvbZRWh90MhwHG+Fxm6OROBdjKPCF+HQobjuJ/Kuf8Chs8CR45X1aqotxrMj7OxTdsXe8sXuGVA==", + "version": "0.215.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.215.0.tgz", + "integrity": "sha512-cWwBvaV+vkXHkSoTYR8hGw+AW03UlgTr6xtrUKOMeum3T+8vffYXIfXu6KY5MLu8O9QtoBKqaKWw9I5xoOepng==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.217.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-logs": "0.217.0", - "@opentelemetry/sdk-metrics": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1", - "protobufjs": "8.0.1" + "@opentelemetry/api-logs": "0.215.0", + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0", + "@opentelemetry/sdk-logs": "0.215.0", + "@opentelemetry/sdk-metrics": "2.7.0", + "@opentelemetry/sdk-trace-base": "2.7.0", + "protobufjs": "^8.0.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4148,15 +4206,31 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.0.tgz", + "integrity": "sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-logs": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.217.0.tgz", - "integrity": "sha512-BB+PcHItcZDL63dPMW+mJvwN9rk37wuIDjRxbVlg6pPDvDR/7GL7UJHbGsllgoggOoTimsKgENaWPoGch/oE1A==", + "version": "0.215.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.215.0.tgz", + "integrity": "sha512-y3ucOmphzc4vgBTyIGchs+N/1rkACmoka8QalT2z1LBNM232Z17zMYayHcMl+dgMoOadZ0b72UZv7mDtqy1cFA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.217.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", + "@opentelemetry/api-logs": "0.215.0", + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4166,14 +4240,30 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.0.tgz", + "integrity": "sha512-Vd7h95av/LYRsAVN7wbprvvJnHkq7swMXAo7Uad0Uxf9jl6NSReLa0JNivrcc5BVIx/vl2t+cgdVQQbnVhsR9w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.0.tgz", + "integrity": "sha512-Yg9zEXJB50DLVLpsKPk7NmNqlPlS+OvqhJGh0A8oawIOTPOwlm4eXs9BMJV7L79lvEwI+dWtAj+YjTyddV336A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4184,22 +4274,12 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/protobufjs": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.1.tgz", - "integrity": "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.2.0.tgz", + "integrity": "sha512-oI+GC9iPxrQEr6wragljFKH46/r3rNsm6eg7F2fp6kBUMnf6/mesDRdBuF4gK+OyaKJ8N4C1B9s9cCeYdqFikg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" }, @@ -4208,13 +4288,13 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.217.0.tgz", - "integrity": "sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ==", + "version": "0.215.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.215.0.tgz", + "integrity": "sha512-lHrfbmeLSmesGSkkHiqDwOzfaEMSWXdc7q6UoLfbW8byONCb+bE/zkAr0kapN4US1baT/2nbpNT7Cn9XoB96Vg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/otlp-transformer": "0.217.0" + "@opentelemetry/core": "2.7.0", + "@opentelemetry/otlp-transformer": "0.215.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4224,9 +4304,9 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/api-logs": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.217.0.tgz", - "integrity": "sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==", + "version": "0.215.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.215.0.tgz", + "integrity": "sha512-xrFlqhdhUyO8wSRn6DjE0145/HPWSJ5Nm0C7vWua6TdL/FSEAZvEyvdsa9CRXuxo9ebb7j/NEPhEcO62IJ0qUA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -4235,19 +4315,34 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.0.tgz", + "integrity": "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.217.0.tgz", - "integrity": "sha512-MKK8UHKFUOGAvbZRWh90MhwHG+Fxm6OROBdjKPCF+HQobjuJ/Kuf8Chs8CR45X1aqotxrMj7OxTdsXe8sXuGVA==", + "version": "0.215.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.215.0.tgz", + "integrity": "sha512-cWwBvaV+vkXHkSoTYR8hGw+AW03UlgTr6xtrUKOMeum3T+8vffYXIfXu6KY5MLu8O9QtoBKqaKWw9I5xoOepng==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.217.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/sdk-logs": "0.217.0", - "@opentelemetry/sdk-metrics": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1", - "protobufjs": "8.0.1" + "@opentelemetry/api-logs": "0.215.0", + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0", + "@opentelemetry/sdk-logs": "0.215.0", + "@opentelemetry/sdk-metrics": "2.7.0", + "@opentelemetry/sdk-trace-base": "2.7.0", + "protobufjs": "^8.0.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4256,15 +4351,31 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/resources": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.0.tgz", + "integrity": "sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-logs": { - "version": "0.217.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.217.0.tgz", - "integrity": "sha512-BB+PcHItcZDL63dPMW+mJvwN9rk37wuIDjRxbVlg6pPDvDR/7GL7UJHbGsllgoggOoTimsKgENaWPoGch/oE1A==", + "version": "0.215.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.215.0.tgz", + "integrity": "sha512-y3ucOmphzc4vgBTyIGchs+N/1rkACmoka8QalT2z1LBNM232Z17zMYayHcMl+dgMoOadZ0b72UZv7mDtqy1cFA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.217.0", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", + "@opentelemetry/api-logs": "0.215.0", + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4274,14 +4385,30 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.0.tgz", + "integrity": "sha512-Vd7h95av/LYRsAVN7wbprvvJnHkq7swMXAo7Uad0Uxf9jl6NSReLa0JNivrcc5BVIx/vl2t+cgdVQQbnVhsR9w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.0.tgz", + "integrity": "sha512-Yg9zEXJB50DLVLpsKPk7NmNqlPlS+OvqhJGh0A8oawIOTPOwlm4eXs9BMJV7L79lvEwI+dWtAj+YjTyddV336A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", + "@opentelemetry/core": "2.7.0", + "@opentelemetry/resources": "2.7.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4292,22 +4419,12 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/protobufjs": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.1.tgz", - "integrity": "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.2.0.tgz", + "integrity": "sha512-oI+GC9iPxrQEr6wragljFKH46/r3rNsm6eg7F2fp6kBUMnf6/mesDRdBuF4gK+OyaKJ8N4C1B9s9cCeYdqFikg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" }, @@ -4572,9 +4689,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { @@ -4600,9 +4717,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -4618,9 +4735,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { @@ -4894,29 +5011,29 @@ "license": "MIT" }, "node_modules/@secretlint/config-creator": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-13.0.0.tgz", - "integrity": "sha512-HJLoVUqXPSxu1s7b2TSjCIeScOHxD4hvPYysJUOqnKqEs/ZqXwC9uFpRo8qS79cVwe8s4lF+OoLmXnMCyMMnxQ==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-12.3.1.tgz", + "integrity": "sha512-CCRvPfrQLt2fPg3eWTIDGXNcVFQd6ZnvQCZ5lzclV9OF7iRqXQ4l5lfGO8NS68tIZx7YvBKhcO8/eVxdqm89HA==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "13.0.0" + "@secretlint/types": "12.3.1" }, "engines": { "node": ">=22.0.0" } }, "node_modules/@secretlint/config-loader": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-13.0.0.tgz", - "integrity": "sha512-fWRE9aMZ4AHzDNaJNKX1P0W1IS08fFLe0c17jKq8BYhrlCehl2II6HqQXmef5no92SBpAEMLuL0uVAdfrtdigQ==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-12.3.1.tgz", + "integrity": "sha512-PNrxz8tnAU/y5PmfOtKfVb+zEA3I+1iZqP1f9fXvIBtauBKs0h0Y+Cmvj0gG1a34kxaD1aQvFh8qHEhRV05gWg==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/profiler": "13.0.0", - "@secretlint/resolver": "13.0.0", - "@secretlint/types": "13.0.0", - "ajv": "^8.20.0", + "@secretlint/profiler": "12.3.1", + "@secretlint/resolver": "12.3.1", + "@secretlint/types": "12.3.1", + "ajv": "^8.18.0", "debug": "^4.4.3", "rc-config-loader": "^4.1.4" }, @@ -4925,14 +5042,14 @@ } }, "node_modules/@secretlint/core": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-13.0.0.tgz", - "integrity": "sha512-j7Yt1o7sgFsN19N0Q4CGmLOdvf0PG3DE5xMYAoQ42i+h4/7Ui3HfCKcDAycDp4aXDiJAJvluko64GY0yDobAVg==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-12.3.1.tgz", + "integrity": "sha512-ulcfARo1TANr8tWzDO/5cFxSNEEfRzgW6YPHYUijgpH3iYfwtUhWEU/r/BiFGl2PNaGzzVE1N9A6nZ74xbYvUQ==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/profiler": "13.0.0", - "@secretlint/types": "13.0.0", + "@secretlint/profiler": "12.3.1", + "@secretlint/types": "12.3.1", "debug": "^4.4.3", "structured-source": "^4.0.0" }, @@ -4941,17 +5058,17 @@ } }, "node_modules/@secretlint/formatter": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-13.0.0.tgz", - "integrity": "sha512-OOskMV3r7yJQFo+0PvrRalRYKwMa2kdx9hrMiO9RYshPGn+155084j0uIziburXhdQmRhhrCe+KFln87KHm5pQ==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-12.3.1.tgz", + "integrity": "sha512-SXpTiRzuuFNbHa59zk0eUxFOB/LYxnuHSfqq7zU9lIt0z5rox6NrnN9WWkoQai2V9s7n3VqUVUpZqnhxQ2Jzpw==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/resolver": "13.0.0", - "@secretlint/types": "13.0.0", - "@textlint/linter-formatter": "^15.6.0", - "@textlint/module-interop": "^15.6.0", - "@textlint/types": "^15.6.0", + "@secretlint/resolver": "12.3.1", + "@secretlint/types": "12.3.1", + "@textlint/linter-formatter": "^15.5.4", + "@textlint/module-interop": "^15.5.4", + "@textlint/types": "^15.5.4", "chalk": "^5.6.2", "debug": "^4.4.3", "pluralize": "^8.0.0", @@ -4977,18 +5094,18 @@ } }, "node_modules/@secretlint/node": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-13.0.0.tgz", - "integrity": "sha512-256dKkIHB4cXXBT9Cr/tcwf1n3IxCo5sk8Q9z4QXDux+IAUKjRxyk3c02LcJXgTc3GTrZGh+F1s17vUo3qJG+g==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-12.3.1.tgz", + "integrity": "sha512-1T08nqwWIJqSRrfkebk4Op5MwYgNnB6gwjv9v+X+V+HEIeG1GB/EgH8CJa8jK4uYdhUuaKyXpu36FIbjNa1wqA==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-loader": "13.0.0", - "@secretlint/core": "13.0.0", - "@secretlint/formatter": "13.0.0", - "@secretlint/profiler": "13.0.0", - "@secretlint/source-creator": "13.0.0", - "@secretlint/types": "13.0.0", + "@secretlint/config-loader": "12.3.1", + "@secretlint/core": "12.3.1", + "@secretlint/formatter": "12.3.1", + "@secretlint/profiler": "12.3.1", + "@secretlint/source-creator": "12.3.1", + "@secretlint/types": "12.3.1", "debug": "^4.4.3", "p-map": "^7.0.4" }, @@ -4997,23 +5114,23 @@ } }, "node_modules/@secretlint/profiler": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-13.0.0.tgz", - "integrity": "sha512-jQb/UBs0kgbzGGeQ+i4LuSSHvPwscp5Ge9UC4/izxNi3guwBTkE/bsREvbEaGR80qtn8fmWZytP88oWYMMrN2g==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-12.3.1.tgz", + "integrity": "sha512-lztyqJPTfkY0Ze9P7vNs3zm7p2Wq1+4ilFXVrxin0sDyFVXpkt+0+vsKsmdx9yBHabxrLDZgxa7fIsfV721cLw==", "dev": true, "license": "MIT" }, "node_modules/@secretlint/resolver": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-13.0.0.tgz", - "integrity": "sha512-5mUTxb+CPl/589Efq5L4SBqJ2yDguKNVZ7udCPiQasI9jS0h1P/CBWfqp+eoKlSoEggu2RKbMHdRiidRfnRXQQ==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-12.3.1.tgz", + "integrity": "sha512-/QwcX5azKRdz9mBIbTBUsqp+cmWQZYGNdOHLbsMOBTLXa7KoEBffhmeaMSc0kNSrdgbgfu/7j+qeeaF4QwJf3A==", "dev": true, "license": "MIT" }, "node_modules/@secretlint/secretlint-rule-preset-recommend": { - "version": "12.3.1", - "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-12.3.1.tgz", - "integrity": "sha512-w9x9rIP1+qhV0k9k15aSIBo+6NrM4npAYIoNs7UmKIxuQSA6b91rhWpdMxgCv2/EQtym+gpuyN7rTSvG5wlYlw==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-12.2.0.tgz", + "integrity": "sha512-n4qknL6vYRelmyrAyV/Z8I85c6jS6yF/ZxpgcqebjJuECIiel8OT2wIVIq9vk0MwlQN35skaQu0KvfM8uuGeyA==", "dev": true, "license": "MIT", "engines": { @@ -5021,13 +5138,13 @@ } }, "node_modules/@secretlint/source-creator": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-13.0.0.tgz", - "integrity": "sha512-+whDtfudstqU0/rt787EDdqvNCna/c2REw9BOHYAcylZ2tpyL35gi0z7byUcqu4aJcI1z5epG7APcM0nd50PGg==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-12.3.1.tgz", + "integrity": "sha512-RCkmyKdoe6VFWMzzVm5a9W+a+ptJSusVX+YOrcNy/heklMIWLg0bL+HYFcyYCm8rU2dRq2HuSYTOamDjNs0LZg==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/types": "13.0.0", + "@secretlint/types": "12.3.1", "istextorbinary": "^9.5.0" }, "engines": { @@ -5035,27 +5152,26 @@ } }, "node_modules/@secretlint/types": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-13.0.0.tgz", - "integrity": "sha512-C0tVelPjw8UHNeUgAMMWq85EWEItL+evpICrOezhM+jETYUYTNdnQ8dcEOuFJrevojQBxY2ygeHPiJoqkWPmNA==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-12.3.1.tgz", + "integrity": "sha512-Qv3fKvPkzUJpS9Ps6m2EPjC0RdxS2ZZrRfZAhIdl2u0zSjgf+Z0+AaCngmHRR+3Vtbw6s2FrCf4T6mLirm8Hgg==", "dev": true, "license": "MIT", "engines": { "node": ">=22.0.0" } }, - "node_modules/@secretlint/walker": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@secretlint/walker/-/walker-13.0.0.tgz", - "integrity": "sha512-Q7+yhgKvSUA7dS8J8asO/04JfmvUnQRW9eB9CrgTmGY7m6klAHkgAyNaBw/JAe5ISxQes379su3xxa8TS9AZ/g==", + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "dev": true, "license": "MIT", - "dependencies": { - "ignore": "^7.0.5", - "picomatch": "^4.0.4" - }, "engines": { - "node": ">=22.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@smithy/chunked-blob-reader": { @@ -5785,24 +5901,24 @@ "license": "MIT" }, "node_modules/@textlint/ast-node-types": { - "version": "15.6.0", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.6.0.tgz", - "integrity": "sha512-CxZHFbYAU7J0A4izz31wV2ZZfySR6aVj2OSR6/3tppZm7VV6hM7nA7sutsLwIiBL/v4lsB1RM79l4Dc/VrH4qw==", + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.6.1.tgz", + "integrity": "sha512-KXUhbpBctWkSumyNwFQBufwWCcjdxARGVnlH6AfERaFAnpi300L8og+l2Hs/bIqkXu26T5tIs7jNnRgUs20hmQ==", "dev": true, "license": "MIT" }, "node_modules/@textlint/linter-formatter": { - "version": "15.6.0", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.6.0.tgz", - "integrity": "sha512-IwHRhjwxs0a5t1eNAoKAdV224CDca38LyopPofXpwO/d0J75wBvzf/cBHXNl4TMsLKhYGtR83UprcLEKj/gZsA==", + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.6.1.tgz", + "integrity": "sha512-mrLdBQkySnUsznPCfOuzyZk3381bJoaK2hPzwmRvOqUeRm04SdPEqN3rkqnbeB4Vw61d1z6gM+kJSS0y8+Zmog==", "dev": true, "license": "MIT", "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "15.6.0", - "@textlint/resolver": "15.6.0", - "@textlint/types": "15.6.0", + "@textlint/module-interop": "15.6.1", + "@textlint/resolver": "15.6.1", + "@textlint/types": "15.6.1", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", @@ -5845,27 +5961,27 @@ } }, "node_modules/@textlint/module-interop": { - "version": "15.6.0", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.6.0.tgz", - "integrity": "sha512-MHY6pJx9i5kOlrvUSK51887tYZjHAV2qnr6unBm7LtBLGDFo93utdYqHyWep8r9QLsilQdeijWtufJI46z4v4w==", + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.6.1.tgz", + "integrity": "sha512-hrsG9S7dlTPoFQ9ImKCHt1I7uPGt25lBXoc/ENP0U47tETAsg8mwjwHBQ4SEEH/X+AXYIEg6+saPyKAj08Aa+Q==", "dev": true, "license": "MIT" }, "node_modules/@textlint/resolver": { - "version": "15.6.0", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.6.0.tgz", - "integrity": "sha512-T1l2Gd3455pwtm0cTewhX/LLy3bL9z6/Fu/am+jj+jjGfXVoknYkjfkZEKrjHlA7xzay0EfUKnu//teYemLeZw==", + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.6.1.tgz", + "integrity": "sha512-kC8MFJH9mIoTtw1IPupsl9k2KF/xj48ZqJoEfVmAqJ2yqd7gvvNRAgndOIBTZOLHDyFK1ouHpFPhW3ID/kNcmw==", "dev": true, "license": "MIT" }, "node_modules/@textlint/types": { - "version": "15.6.0", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.6.0.tgz", - "integrity": "sha512-CvgYb1PiqF4BGyoZebGWzAJCZ4ChJAZ9gtWjpQIMKE4Xe2KlSwDA8m8MsiZIV321f5Ibx38BMjC1Z/2ZYP2GQg==", + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.6.1.tgz", + "integrity": "sha512-dRdyTAMRaqcU5eHpJWgTq9kcKGErs+cVVFwNTP3gUAFDJxEVxdOlJU2zjMgzJAzf/4WfOE6UJ56Mmn09acxmzw==", "dev": true, "license": "MIT", "dependencies": { - "@textlint/ast-node-types": "15.6.0" + "@textlint/ast-node-types": "15.6.1" } }, "node_modules/@trivago/prettier-plugin-sort-imports": { @@ -7101,9 +7217,9 @@ } }, "node_modules/aws-cdk-lib": { - "version": "2.251.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.251.0.tgz", - "integrity": "sha512-H1Jfz2Oyejn+yG24i+By9fZpYfg+E3h1XnFCF2wnt/MyGOTIePRph7MRGkX73ap10ERSpmd0Ly58OLVykFoSQA==", + "version": "2.250.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.250.0.tgz", + "integrity": "sha512-8U8/S9VcmKSc3MHZWiB7P0IecgXoohI8Ya3dgtZMgbzC4mB+MEQmsYBeNgm4vzGQdRos8HjQLnFX1IBlZh7jQA==", "bundleDependencies": [ "@balena/dockerignore", "@aws-cdk/cloud-assembly-api", @@ -7123,8 +7239,8 @@ "dependencies": { "@aws-cdk/asset-awscli-v1": "2.2.273", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1", - "@aws-cdk/cloud-assembly-api": "^2.2.2", - "@aws-cdk/cloud-assembly-schema": "^53.18.0", + "@aws-cdk/cloud-assembly-api": "^2.2.0", + "@aws-cdk/cloud-assembly-schema": "^53.0.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", "fs-extra": "^11.3.3", @@ -7145,7 +7261,7 @@ } }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { - "version": "2.2.2", + "version": "2.2.0", "bundleDependencies": [ "jsonschema", "semver" @@ -7161,7 +7277,7 @@ "node": ">= 18.0.0" }, "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": ">=53.15.0" + "@aws-cdk/cloud-assembly-schema": ">=53.0.0" } }, "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { @@ -7913,9 +8029,9 @@ } }, "node_modules/cdk-from-cfn": { - "version": "0.297.0", - "resolved": "https://registry.npmjs.org/cdk-from-cfn/-/cdk-from-cfn-0.297.0.tgz", - "integrity": "sha512-ZyiugKPe9QYmfXNwbjBhc8sgbos7E0mQfo9P3/vrID8iKTsf5YZswPDu526sBTg4Fpx94N08UJZjLepRxaK4Ng==", + "version": "0.295.0", + "resolved": "https://registry.npmjs.org/cdk-from-cfn/-/cdk-from-cfn-0.295.0.tgz", + "integrity": "sha512-HNQu3TfNTHZNlxh/o0XxhMMSt3uDFDtMxxO2wZGvZpHwvjZLLFSCHooMbMGj75vtyqNmqKxQdR9WQSTcW3oIpg==", "license": "MIT OR Apache-2.0" }, "node_modules/chai": { @@ -9587,27 +9703,6 @@ "xml-naming": "^0.1.0" } }, - "node_modules/fast-xml-parser": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", - "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -9977,6 +10072,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", + "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.5", + "is-path-inside": "^4.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -10890,6 +11006,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -13503,9 +13632,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -13725,22 +13854,22 @@ } }, "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", + "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" }, @@ -13847,9 +13976,9 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14372,19 +14501,19 @@ "license": "MIT" }, "node_modules/secretlint": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-13.0.0.tgz", - "integrity": "sha512-bc4dzeaYMiuorNAb4cPoOX87gb+w0kiReZaUEx9iu8u0/PrkMj5QCm6pXji/NDc4Z6HUZO5ihfd0HY/4iPzskQ==", + "version": "12.3.1", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-12.3.1.tgz", + "integrity": "sha512-wv8TKCjU5hbBxo5jKEX8wIE78VAoL0Ux7pu18+TxtbICMZ2OCbu6EmO3OJLbUbyfUXSPVryNLNmGVgvwY6Z0xw==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/config-creator": "13.0.0", - "@secretlint/formatter": "13.0.0", - "@secretlint/node": "13.0.0", - "@secretlint/profiler": "13.0.0", - "@secretlint/resolver": "13.0.0", - "@secretlint/walker": "13.0.0", + "@secretlint/config-creator": "12.3.1", + "@secretlint/formatter": "12.3.1", + "@secretlint/node": "12.3.1", + "@secretlint/profiler": "12.3.1", + "@secretlint/resolver": "12.3.1", "debug": "^4.4.3", + "globby": "^16.2.0", "read-pkg": "^10.1.0" }, "bin": { @@ -14619,6 +14748,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", diff --git a/package.json b/package.json index c1134abd3..ec426934e 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "@aws-sdk/client-bedrock": "^3.1012.0", "@aws-sdk/client-bedrock-agent": "^3.1012.0", "@aws-sdk/client-bedrock-agentcore": "^3.1020.0", - "@aws-sdk/client-bedrock-agentcore-control": "^3.1020.0", + "@aws-sdk/client-bedrock-agentcore-control": "^3.1039.0", "@aws-sdk/client-bedrock-runtime": "^3.893.0", "@aws-sdk/client-cloudformation": "^3.893.0", "@aws-sdk/client-cloudwatch-logs": "^3.893.0", @@ -90,7 +90,7 @@ "@aws/agent-inspector": "0.3.0", "@commander-js/extra-typings": "^14.0.0", "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-metrics-otlp-http": "^0.217.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.215.0", "@opentelemetry/otlp-transformer": "^0.213.0", "@opentelemetry/resources": "^2.6.1", "@opentelemetry/sdk-metrics": "^2.6.1", @@ -141,7 +141,7 @@ "lint-staged": "^16.2.7", "node-pty": "^1.1.0", "prettier": "^3.7.4", - "secretlint": "^13.0.0", + "secretlint": "^12.2.0", "tsx": "^4.21.0", "typescript": "^5", "typescript-eslint": "^8.50.1", @@ -149,11 +149,15 @@ }, "overridesComments": { "minimatch": "GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74: minimatch 10.0.0-10.2.2 has ReDoS vulnerabilities. Multiple transitive deps (eslint, typescript-eslint, eslint-plugin-import, eslint-plugin-react, prettier-plugin-sort-imports, aws-cdk-lib) pin older versions. Remove this override once upstream packages update their minimatch dependency to >=10.2.3.", - "glob": "glob <12 is deprecated and emits npm install warnings (https://github.com/isaacs/node-glob). Pulled in transitively via archiver-utils@5.0.2 (latest), which still pins glob@^10.0.0. archiver-utils only uses glob.sync(pattern, options), which remains compatible in glob@13. Remove this override once archiver-utils updates its glob dependency." + "glob": "glob <12 is deprecated and emits npm install warnings (https://github.com/isaacs/node-glob). Pulled in transitively via archiver-utils@5.0.2 (latest), which still pins glob@^10.0.0. archiver-utils only uses glob.sync(pattern, options), which remains compatible in glob@13. Remove this override once archiver-utils updates its glob dependency.", + "fast-xml-parser": "GHSA-8gc5-j5rx-235r, GHSA-jp2q-39xq-3w4g: fast-xml-parser <=5.5.6 has entity expansion bypass (CVE-2026-33036, CVE-2026-33349). Transitive via @aws-sdk/xml-builder. Remove once @aws-sdk updates to fast-xml-parser >=5.5.7.", + "@aws-sdk/xml-builder": "aws/aws-sdk-js-v3#7867: @aws-sdk/xml-builder <3.972.14 does not configure maxTotalExpansions on fast-xml-parser, causing 'Entity expansion limit exceeded' on large CloudFormation responses. Remove once @aws-sdk/client-* deps are bumped past 3.972.14." }, "overrides": { "minimatch": "10.2.4", - "glob": "^13.0.0" + "glob": "^13.0.0", + "fast-xml-parser": "5.5.7", + "@aws-sdk/xml-builder": "3.972.15" }, "engines": { "node": ">=20" diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index 55512bb47..9c9a3a921 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -82,7 +82,7 @@ primitives/ ├── registry.ts # Singleton instances + ALL_PRIMITIVES array ├── credential-utils.ts # Shared credential env var name computation ├── constants.ts # SOURCE_CODE_NOTE and other shared constants -├── types.ts # AddResult, RemovableResource, RemovalResult, etc. +├── types.ts # RemovableResource, AddScreenComponent, etc. └── index.ts # Barrel exports ``` @@ -92,8 +92,8 @@ Every primitive extends `BasePrimitive` and implements: - `kind` — resource identifier (`'agent'`, `'memory'`, `'identity'`, `'gateway'`, `'mcp-tool'`) - `label` — human-readable name (`'Agent'`, `'Memory'`, `'Identity'`) -- `add(options)` — create a resource, returns `AddResult` -- `remove(name)` — remove a resource, returns `RemovalResult` +- `add(options)` — create a resource, returns `Result` +- `remove(name)` — remove a resource, returns `Result` - `previewRemove(name)` — preview what removal will do - `getRemovable()` — list resources available for removal - `registerCommands(addCmd, removeCmd)` — register CLI subcommands @@ -119,7 +119,8 @@ BasePrimitive provides shared helpers: - **Absorb, don't wrap.** Each primitive owns its logic directly. Do not create facade files that delegate to primitives. - **No backward-compatibility shims.** This is a CLI, not a library. If the CLI functions the same, delete old files. -- **Use `{ success, error? }` result format** throughout (never `{ ok, error }`). See `AddResult` and `RemovalResult`. +- **Use the discriminated `Result` union** from `src/lib/result.ts` throughout. See typed error classes in + `src/lib/errors/types.ts`. - **Dynamic imports for ink/React only.** TUI components (ink, react, screen components) must be dynamically imported inside Commander action handlers to prevent esbuild async module propagation issues. All other imports go at the top of the file. See the esbuild section below. diff --git a/src/cli/__tests__/errors.test.ts b/src/cli/__tests__/errors.test.ts index 4a8d2c1cf..663cd3508 100644 --- a/src/cli/__tests__/errors.test.ts +++ b/src/cli/__tests__/errors.test.ts @@ -1,5 +1,5 @@ +import { AgentAlreadyExistsError, toError } from '../../lib'; import { - AgentAlreadyExistsError, getErrorMessage, isChangesetInProgressError, isExpiredTokenError, @@ -204,4 +204,35 @@ describe('errors', () => { expect(isChangesetInProgressError({})).toBe(false); }); }); + + describe('toError', () => { + it('returns Error instance as-is', () => { + const err = new Error('original'); + expect(toError(err)).toBe(err); + }); + + it('wraps string in Error', () => { + const result = toError('string error'); + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('string error'); + }); + + it('handles null', () => { + const result = toError(null); + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('null'); + }); + + it('handles undefined', () => { + const result = toError(undefined); + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('undefined'); + }); + + it('handles non-Error objects', () => { + const result = toError({ code: 42 }); + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('[object Object]'); + }); + }); }); diff --git a/src/cli/aws/__tests__/transaction-search.test.ts b/src/cli/aws/__tests__/transaction-search.test.ts index 67ccd0ac6..307063a61 100644 --- a/src/cli/aws/__tests__/transaction-search.test.ts +++ b/src/cli/aws/__tests__/transaction-search.test.ts @@ -1,4 +1,5 @@ import { enableTransactionSearch } from '../transaction-search.js'; +import assert from 'node:assert'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const { mockAppSignalsSend, mockLogsSend, mockXRaySend } = vi.hoisted(() => ({ @@ -162,8 +163,8 @@ describe('enableTransactionSearch', () => { const result = await enableTransactionSearch('us-east-1', '123456789012'); - expect(result.success).toBe(false); - expect(result.error).toContain('Insufficient permissions to enable Application Signals'); + assert(!result.success); + expect(result.error.message).toContain('Insufficient permissions to enable Application Signals'); }); it('returns error when Application Signals fails with generic error', async () => { @@ -171,8 +172,8 @@ describe('enableTransactionSearch', () => { const result = await enableTransactionSearch('us-east-1', '123456789012'); - expect(result.success).toBe(false); - expect(result.error).toContain('Failed to enable Application Signals'); + assert(!result.success); + expect(result.error.message).toContain('Failed to enable Application Signals'); }); it('returns error when CloudWatch Logs policy fails with AccessDenied', async () => { @@ -183,8 +184,8 @@ describe('enableTransactionSearch', () => { const result = await enableTransactionSearch('us-east-1', '123456789012'); - expect(result.success).toBe(false); - expect(result.error).toContain('Insufficient permissions to configure CloudWatch Logs policy'); + assert(!result.success); + expect(result.error.message).toContain('Insufficient permissions to configure CloudWatch Logs policy'); }); it('returns error when trace destination fails', async () => { @@ -194,8 +195,8 @@ describe('enableTransactionSearch', () => { const result = await enableTransactionSearch('us-east-1', '123456789012'); - expect(result.success).toBe(false); - expect(result.error).toContain('Failed to configure trace destination'); + assert(!result.success); + expect(result.error.message).toContain('Failed to configure trace destination'); }); it('returns error when indexing rule update fails', async () => { @@ -214,8 +215,8 @@ describe('enableTransactionSearch', () => { const result = await enableTransactionSearch('us-east-1', '123456789012'); - expect(result.success).toBe(false); - expect(result.error).toContain('Failed to configure indexing rules'); + assert(!result.success); + expect(result.error.message).toContain('Failed to configure indexing rules'); }); it('does not proceed to later steps when an earlier step fails', async () => { diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index 162c2b3a6..81cc723db 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -467,6 +467,7 @@ export interface GetEvaluatorResult { llmAsAJudge?: GetEvaluatorLlmConfig; codeBased?: GetEvaluatorCodeBasedConfig; }; + kmsKeyArn?: string; tags?: Record; } @@ -545,6 +546,7 @@ export async function getEvaluator(options: GetEvaluatorOptions): Promise { +): Promise { const credentials = getCredentialProvider(); // Step 1: Enable Application Signals (creates service-linked role, idempotent) @@ -44,9 +41,14 @@ export async function enableTransactionSearch( } catch (err: unknown) { const message = getErrorMessage(err); if (isAccessDeniedError(err)) { - return { success: false, error: `Insufficient permissions to enable Application Signals: ${message}` }; + return { + success: false, + error: new AccessDeniedError(`Insufficient permissions to enable Application Signals: ${message}`, { + cause: err, + }), + }; } - return { success: false, error: `Failed to enable Application Signals: ${message}` }; + return { success: false, error: new Error(`Failed to enable Application Signals: ${message}`, { cause: err }) }; } // Step 2: Create CloudWatch Logs resource policy for X-Ray (if needed) @@ -80,9 +82,17 @@ export async function enableTransactionSearch( } catch (err: unknown) { const message = getErrorMessage(err); if (isAccessDeniedError(err)) { - return { success: false, error: `Insufficient permissions to configure CloudWatch Logs policy: ${message}` }; + return { + success: false, + error: new AccessDeniedError(`Insufficient permissions to configure CloudWatch Logs policy: ${message}`, { + cause: err, + }), + }; } - return { success: false, error: `Failed to configure CloudWatch Logs policy: ${message}` }; + return { + success: false, + error: new Error(`Failed to configure CloudWatch Logs policy: ${message}`, { cause: err }), + }; } const xrayClient = new XRayClient({ region, credentials }); @@ -96,9 +106,14 @@ export async function enableTransactionSearch( } catch (err: unknown) { const message = getErrorMessage(err); if (isAccessDeniedError(err)) { - return { success: false, error: `Insufficient permissions to configure trace destination: ${message}` }; + return { + success: false, + error: new AccessDeniedError(`Insufficient permissions to configure trace destination: ${message}`, { + cause: err, + }), + }; } - return { success: false, error: `Failed to configure trace destination: ${message}` }; + return { success: false, error: new Error(`Failed to configure trace destination: ${message}`, { cause: err }) }; } // Step 4: Set indexing to 100% on the built-in Default rule (always exists, idempotent) @@ -112,9 +127,14 @@ export async function enableTransactionSearch( } catch (err: unknown) { const message = getErrorMessage(err); if (isAccessDeniedError(err)) { - return { success: false, error: `Insufficient permissions to configure indexing rules: ${message}` }; + return { + success: false, + error: new AccessDeniedError(`Insufficient permissions to configure indexing rules: ${message}`, { + cause: err, + }), + }; } - return { success: false, error: `Failed to configure indexing rules: ${message}` }; + return { success: false, error: new Error(`Failed to configure indexing rules: ${message}`, { cause: err }) }; } return { success: true }; diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index a066b1cc3..c1dd6641f 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -41,13 +41,6 @@ export interface AddAgentOptions extends VpcOptions { json?: boolean; } -export interface AddAgentResult { - success: boolean; - agentName?: string; - agentPath?: string; - error?: string; -} - // Gateway types export interface AddGatewayOptions { name?: string; @@ -68,12 +61,6 @@ export interface AddGatewayOptions { json?: boolean; } -export interface AddGatewayResult { - success: boolean; - gatewayName?: string; - error?: string; -} - // Gateway Target types export interface AddGatewayTargetOptions { name?: string; @@ -100,13 +87,6 @@ export interface AddGatewayTargetOptions { json?: boolean; } -export interface AddGatewayTargetResult { - success: boolean; - toolName?: string; - sourcePath?: string; - error?: string; -} - // Memory types (v2: no owner/user concept) export interface AddMemoryOptions { name?: string; @@ -119,12 +99,6 @@ export interface AddMemoryOptions { json?: boolean; } -export interface AddMemoryResult { - success: boolean; - memoryName?: string; - error?: string; -} - // Credential types (v2: credential, no owner/user concept) export interface AddCredentialOptions { name?: string; @@ -139,9 +113,3 @@ export interface AddCredentialOptions { /** @deprecated Use AddCredentialOptions */ export type AddIdentityOptions = AddCredentialOptions; - -export interface AddCredentialResult { - success: boolean; - credentialName?: string; - error?: string; -} diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index a00397f38..0f97de294 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -1,4 +1,13 @@ -import { APP_DIR, CONFIG_DIR, ConfigIO, setEnvVar, setSessionProjectRoot } from '../../../lib'; +import { + APP_DIR, + CONFIG_DIR, + ConfigIO, + DependencyCheckError, + GitInitError, + setEnvVar, + setSessionProjectRoot, + toError, +} from '../../../lib'; import type { BuildType, DeployedState, @@ -8,7 +17,6 @@ import type { SDKFramework, TargetLanguage, } from '../../../schema'; -import { getErrorMessage } from '../../errors'; import { checkCreateDependencies } from '../../external-requirements'; import { initGitRepo, setupPythonProject, writeEnvFile, writeGitignore } from '../../operations'; import { createConfigBundleForAgent } from '../../operations/agent/config-bundle-defaults'; @@ -57,7 +65,11 @@ export async function createProject(options: CreateProjectOptions): Promise 0 ? depWarnings : undefined, + }; } } @@ -93,7 +105,11 @@ export async function createProject(options: CreateProjectOptions): Promise 0 ? depWarnings : undefined, + }; } onProgress?.('Initialize git repository', 'done'); } @@ -104,7 +120,7 @@ export async function createProject(options: CreateProjectOptions): Promise 0 ? depWarnings : undefined, }; } catch (err) { - return { success: false, error: getErrorMessage(err), warnings: depWarnings }; + return { success: false, error: toError(err), warnings: depWarnings.length > 0 ? depWarnings : undefined }; } } @@ -174,7 +190,11 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P // Fail on errors if (!depCheck.passed) { - return { success: false, error: depCheck.errors.join('\n'), warnings: depWarnings }; + return { + success: false, + error: new DependencyCheckError(depCheck.errors), + warnings: depWarnings.length > 0 ? depWarnings : undefined, + }; } // First create the base project (skip dependency check since we already did it) @@ -207,7 +227,11 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P }); if (!importResult.success) { onProgress?.('Import agent from Bedrock', 'error'); - return { success: false, error: importResult.error, warnings: depWarnings }; + return { + success: false, + error: importResult.error, + warnings: depWarnings.length > 0 ? depWarnings : undefined, + }; } onProgress?.('Import agent from Bedrock', 'done'); return { @@ -217,7 +241,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P warnings: depWarnings.length > 0 ? depWarnings : undefined, }; } catch (err) { - return { success: false, error: getErrorMessage(err), warnings: depWarnings }; + return { success: false, error: toError(err), warnings: depWarnings.length > 0 ? depWarnings : undefined }; } } @@ -310,7 +334,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P warnings: depWarnings.length > 0 ? depWarnings : undefined, }; } catch (err) { - return { success: false, error: getErrorMessage(err), warnings: depWarnings }; + return { success: false, error: toError(err), warnings: depWarnings.length > 0 ? depWarnings : undefined }; } } diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 42c8c7403..a21ece491 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -1,4 +1,4 @@ -import { getWorkingDirectory } from '../../../lib'; +import { getWorkingDirectory, serializeResult } from '../../../lib'; import type { BuildType, ModelProvider, @@ -9,6 +9,18 @@ import type { } from '../../../schema'; import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema'; import { getErrorMessage } from '../../errors'; +import { runCliCommand } from '../../telemetry/cli-command-run.js'; +import { + AgentType, + Build, + Framework, + Language, + Memory, + ModelProvider as ModelProviderEnum, + NetworkMode as NetworkModeEnum, + Protocol, + standardize, +} from '../../telemetry/schemas/common-shapes.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireTTY } from '../../tui/guards'; import { CreateScreen } from '../../tui/screens/create'; @@ -79,22 +91,21 @@ async function handleCreateCLI(options: CreateOptions): Promise { const name = options.name ?? options.projectName; const projectName = options.projectName ?? name; - const validation = validateCreateOptions(options, cwd); - if (!validation.valid) { - if (options.json) { - console.log(JSON.stringify({ success: false, error: validation.error })); - } else { - console.error(validation.error); - } - process.exit(1); - } - - // Handle dry-run mode + // Handle dry-run mode (no telemetry for dry-run) if (options.dryRun) { + const validation = validateCreateOptions(options, cwd); + if (!validation.valid) { + if (options.json) { + console.log(JSON.stringify({ success: false, error: validation.error })); + } else { + console.error(validation.error); + } + process.exit(1); + } const result = getDryRunInfo({ name: name!, projectName, cwd, language: options.language }); if (options.json) { - console.log(JSON.stringify(result)); - } else { + console.log(JSON.stringify(serializeResult(result))); + } else if (result.success) { console.log('Dry run - would create:'); for (const path of result.wouldCreate ?? []) { console.log(` ${path}`); @@ -103,74 +114,99 @@ async function handleCreateCLI(options: CreateOptions): Promise { process.exit(0); } - const green = '\x1b[32m'; - const reset = '\x1b[0m'; + const knownAttrs = { + language: standardize(Language, options.language), + framework: standardize(Framework, options.framework), + model_provider: standardize(ModelProviderEnum, options.modelProvider), + memory: standardize(Memory, options.memory ?? 'none'), + protocol: standardize(Protocol, options.protocol ?? 'http'), + build: standardize(Build, options.build ?? 'codezip'), + agent_type: standardize(AgentType, options.type ?? 'create'), + network_mode: standardize(NetworkModeEnum, options.networkMode ?? 'public'), + has_agent: options.agent !== false, + }; - // Progress callback for real-time output - const onProgress: ProgressCallback | undefined = options.json - ? undefined - : (step, status) => { - if (status === 'done') { - console.log(`${green}[done]${reset} ${step}`); - } else if (status === 'error') { - console.log(`\x1b[31m[error]${reset} ${step}`); - } - // 'start' is silent - we only show when done - }; + await runCliCommand( + 'create', + !!options.json, + async () => { + const validation = validateCreateOptions(options, cwd); + if (!validation.valid) { + throw new Error(validation.error); + } + const green = '\x1b[32m'; + const reset = '\x1b[0m'; - // Commander.js --no-agent sets agent=false, not noAgent=true - const skipAgent = options.agent === false; + // Progress callback for real-time output + const onProgress: ProgressCallback | undefined = options.json + ? undefined + : (step, status) => { + if (status === 'done') { + console.log(`${green}[done]${reset} ${step}`); + } else if (status === 'error') { + console.log(`\x1b[31m[error]${reset} ${step}`); + } + // 'start' is silent - we only show when done + }; - const result = skipAgent - ? await createProject({ - name: projectName!, - cwd, - skipGit: options.skipGit, - skipInstall: options.skipInstall, - onProgress, - }) - : await createProjectWithAgent({ - name: name!, - projectName, - cwd, - type: options.type as 'create' | 'import' | undefined, - buildType: (options.build as BuildType) ?? 'CodeZip', - language: (options.language as TargetLanguage) ?? (options.type === 'import' ? 'Python' : undefined), - framework: options.framework as SDKFramework | undefined, - modelProvider: options.modelProvider as ModelProvider | undefined, - apiKey: options.apiKey, - memory: (options.memory as 'none' | 'shortTerm' | 'longAndShortTerm') ?? 'none', - protocol: options.protocol as ProtocolMode | undefined, - agentId: options.agentId, - agentAliasId: options.agentAliasId, - region: options.region, - networkMode: options.networkMode as NetworkMode | undefined, - subnets: parseCommaSeparatedList(options.subnets), - securityGroups: parseCommaSeparatedList(options.securityGroups), - idleTimeout: options.idleTimeout ? Number(options.idleTimeout) : undefined, - maxLifetime: options.maxLifetime ? Number(options.maxLifetime) : undefined, - sessionStorageMountPath: options.sessionStorageMountPath, - withConfigBundle: options.withConfigBundle, - skipGit: options.skipGit, - skipInstall: options.skipInstall, - skipPythonSetup: options.skipPythonSetup, - onProgress, - }); + // Commander.js --no-agent sets agent=false, not noAgent=true + const skipAgent = options.agent === false; - if (options.json) { - console.log(JSON.stringify(result)); - } else if (result.success) { - printCreateSummary(projectName!, result.agentName, options.language, options.framework); - if (options.skipInstall) { - console.log( - "\nDependency installation was skipped. Run 'npm install' in agentcore/cdk/ and 'uv sync' in your agent directory manually." - ); - } - } else { - console.error(result.error); - } + const result = skipAgent + ? await createProject({ + name: projectName!, + cwd, + skipGit: options.skipGit, + skipInstall: options.skipInstall, + onProgress, + }) + : await createProjectWithAgent({ + name: name!, + projectName, + cwd, + type: options.type as 'create' | 'import' | undefined, + buildType: (options.build as BuildType) ?? 'CodeZip', + language: (options.language as TargetLanguage) ?? (options.type === 'import' ? 'Python' : undefined), + framework: options.framework as SDKFramework | undefined, + modelProvider: options.modelProvider as ModelProvider | undefined, + apiKey: options.apiKey, + memory: (options.memory as 'none' | 'shortTerm' | 'longAndShortTerm') ?? 'none', + protocol: options.protocol as ProtocolMode | undefined, + agentId: options.agentId, + agentAliasId: options.agentAliasId, + region: options.region, + networkMode: options.networkMode as NetworkMode | undefined, + subnets: parseCommaSeparatedList(options.subnets), + securityGroups: parseCommaSeparatedList(options.securityGroups), + idleTimeout: options.idleTimeout ? Number(options.idleTimeout) : undefined, + maxLifetime: options.maxLifetime ? Number(options.maxLifetime) : undefined, + sessionStorageMountPath: options.sessionStorageMountPath, + withConfigBundle: options.withConfigBundle, + skipGit: options.skipGit, + skipInstall: options.skipInstall, + skipPythonSetup: options.skipPythonSetup, + onProgress, + }); + + if (!result.success) { + throw result.error; + } - process.exit(result.success ? 0 : 1); + if (options.json) { + console.log(JSON.stringify(result)); + } else { + printCreateSummary(projectName!, result.agentName, options.language, options.framework); + if (options.skipInstall) { + console.log( + "\nDependency installation was skipped. Run 'npm install' in agentcore/cdk/ and 'uv sync' in your agent directory manually." + ); + } + } + + return knownAttrs; + }, + knownAttrs + ); } export const registerCreate = (program: Command) => { diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index f762a36eb..cd42c545b 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -1,3 +1,4 @@ +import type { Result } from '../../../lib/result'; import type { VpcOptions } from '../shared/vpc-utils'; export interface CreateOptions extends VpcOptions { @@ -28,12 +29,9 @@ export interface CreateOptions extends VpcOptions { json?: boolean; } -export interface CreateResult { - success: boolean; +export type CreateResult = Result<{ projectPath?: string; agentName?: string; - error?: string; dryRun?: boolean; wouldCreate?: string[]; - warnings?: string[]; -} +}> & { warnings?: string[] }; diff --git a/src/cli/commands/deploy/__tests__/utils.test.ts b/src/cli/commands/deploy/__tests__/utils.test.ts new file mode 100644 index 000000000..8bf07311a --- /dev/null +++ b/src/cli/commands/deploy/__tests__/utils.test.ts @@ -0,0 +1,54 @@ +import type { AgentCoreProjectSpec } from '../../../../schema'; +import { computeDeployAttrs } from '../utils.js'; +import { describe, expect, it } from 'vitest'; + +describe('computeDeployAttrs', () => { + it('computes counts from a populated spec', () => { + const projectSpec = { + runtimes: [{}, {}], + memories: [{}], + credentials: [{}, {}, {}], + evaluators: [{}], + onlineEvalConfigs: [{}, {}], + agentCoreGateways: [{ targets: [{}, {}] }, { targets: [{}] }], + policyEngines: [{ policies: [{}, {}] }, { policies: [{}] }], + } as unknown as Partial; + + expect(computeDeployAttrs(projectSpec, 'diff')).toEqual({ + runtime_count: 2, + memory_count: 1, + credential_count: 3, + evaluator_count: 1, + online_eval_count: 2, + gateway_count: 2, + gateway_target_count: 3, + policy_engine_count: 2, + policy_count: 3, + mode: 'diff', + }); + }); + + it('returns zeros for empty spec', () => { + expect(computeDeployAttrs({}, 'deploy')).toEqual({ + runtime_count: 0, + memory_count: 0, + credential_count: 0, + evaluator_count: 0, + online_eval_count: 0, + gateway_count: 0, + gateway_target_count: 0, + policy_engine_count: 0, + policy_count: 0, + mode: 'deploy', + }); + }); + + it('handles dry-run mode', () => { + const projectSpec = { runtimes: [{}] } as unknown as Partial; + const attrs = computeDeployAttrs(projectSpec, 'dry-run'); + + expect(attrs.runtime_count).toBe(1); + expect(attrs.memory_count).toBe(0); + expect(attrs.mode).toBe('dry-run'); + }); +}); diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 0669dff44..33a040d3f 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,4 +1,4 @@ -import { ConfigIO, SecureCredentials } from '../../../lib'; +import { ConfigIO, ResourceNotFoundError, SecureCredentials, ValidationError, toError } from '../../../lib'; import type { AgentCoreMcpSpec, DeployedState } from '../../../schema'; import { applyTargetRegionToEnv } from '../../aws'; import { validateAwsCredentials } from '../../aws/account'; @@ -120,7 +120,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { process.exit(1); } + // Compute attrs upfront from project spec (available before deploy) + const mode = options.diff ? 'diff' : options.plan ? 'dry-run' : 'deploy'; + const attrs = await new ConfigIO() + .readProjectSpec() + .then(spec => computeDeployAttrs(spec, mode)) + .catch(() => ({ ...DEFAULT_DEPLOY_ATTRS, mode }) as const); + + const { deployResult } = await withCommandRunTelemetry('deploy', attrs, async () => { + const result = await executeDeploy(options).catch( + (e): DeployResult => ({ success: false, error: e instanceof Error ? e : new Error(getErrorMessage(e)) }) + ); + if (!result.success) { + return { success: false as const, error: result.error, deployResult: result }; + } + return { success: true as const, deployResult: result }; + }); + + // ALL output happens here, after telemetry + if (!deployResult.success) { + if (options.json) { + console.log(JSON.stringify(serializeResult(deployResult))); + } else { + console.error(deployResult.error.message); + if (deployResult.logPath) { + console.error(`Log: ${deployResult.logPath}`); + } + } + process.exit(1); + } + + printDeployResult(deployResult, options); + + if (deployResult.postDeployWarnings && deployResult.postDeployWarnings.length > 0) { + process.exit(2); + } + process.exit(0); +} + +async function executeDeploy(options: DeployOptions): Promise { let spinner: NodeJS.Timeout | undefined; // Progress callback for --progress mode @@ -84,55 +126,52 @@ async function handleDeployCLI(options: DeployOptions): Promise { process.stdout.write('\r\x1b[K'); } + return result; +} + +function printDeployResult(result: DeployResult & { success: true }, options: DeployOptions): void { if (options.json) { console.log(JSON.stringify(result)); - } else if (result.success) { - if (options.diff) { - console.log(`\n✓ Diff complete for '${result.targetName}' (stack: ${result.stackName})`); - } else if (options.plan) { - console.log(`\n✓ Dry run complete for '${result.targetName}' (stack: ${result.stackName})`); - console.log('\nRun `agentcore deploy` to deploy.'); - } else { - console.log(`\n✓ Deployed to '${result.targetName}' (stack: ${result.stackName})`); + return; + } - // Show stack outputs in non-JSON mode - if (result.outputs && Object.keys(result.outputs).length > 0) { - console.log('\nOutputs:'); - for (const [key, value] of Object.entries(result.outputs)) { - console.log(` ${key}: ${value}`); - } - } + if (options.diff) { + console.log(`\n✓ Diff complete for '${result.targetName}' (stack: ${result.stackName})`); + } else if (options.plan) { + console.log(`\n✓ Dry run complete for '${result.targetName}' (stack: ${result.stackName})`); + console.log('\nRun `agentcore deploy` to deploy.'); + } else { + console.log(`\n✓ Deployed to '${result.targetName}' (stack: ${result.stackName})`); - if (result.postDeployWarnings && result.postDeployWarnings.length > 0) { - console.log('\n⚠ Post-deploy warnings:'); - for (const warning of result.postDeployWarnings) { - console.log(` ${warning}`); - } + // Show stack outputs in non-JSON mode + if (result.outputs && Object.keys(result.outputs).length > 0) { + console.log('\nOutputs:'); + for (const [key, value] of Object.entries(result.outputs)) { + console.log(` ${key}: ${value}`); } + } - if (result.notes && result.notes.length > 0) { - for (const note of result.notes) { - console.log(`\nNote: ${note}`); - } + if (result.postDeployWarnings && result.postDeployWarnings.length > 0) { + console.log('\n⚠ Post-deploy warnings:'); + for (const warning of result.postDeployWarnings) { + console.log(` ${warning}`); } + } - if (result.nextSteps && result.nextSteps.length > 0) { - console.log(`Next: ${result.nextSteps.join(' | ')}`); + if (result.notes && result.notes.length > 0) { + for (const note of result.notes) { + console.log(`\nNote: ${note}`); } } - if (result.logPath) { - console.log(`\nLog: ${result.logPath}`); - } - } else { - console.error(result.error); - if (result.logPath) { - console.error(`Log: ${result.logPath}`); + if (result.nextSteps && result.nextSteps.length > 0) { + console.log(`Next: ${result.nextSteps.join(' | ')}`); } } - const hasPostDeployWarnings = result.success && result.postDeployWarnings && result.postDeployWarnings.length > 0; - process.exit(result.success ? (hasPostDeployWarnings ? 2 : 0) : 1); + if (result.logPath) { + console.log(`\nLog: ${result.logPath}`); + } } export const registerDeploy = (program: Command) => { diff --git a/src/cli/commands/deploy/types.ts b/src/cli/commands/deploy/types.ts index 44cdc7847..a29b6ef8e 100644 --- a/src/cli/commands/deploy/types.ts +++ b/src/cli/commands/deploy/types.ts @@ -1,3 +1,5 @@ +import type { Result } from '../../../lib/result'; + export interface DeployOptions { target?: string; yes?: boolean; @@ -8,21 +10,16 @@ export interface DeployOptions { diff?: boolean; } -export interface DeployResult { - success: boolean; +export type DeployResult = Result<{ targetName?: string; stackName?: string; outputs?: Record; - logPath?: string; nextSteps?: string[]; notes?: string[]; postDeployWarnings?: string[]; - error?: string; -} +}> & { logPath?: string }; -export interface PreflightResult { - success: boolean; +export type PreflightResult = Result<{ stackNames?: string[]; needsBootstrap?: boolean; - error?: string; -} +}>; diff --git a/src/cli/commands/deploy/utils.ts b/src/cli/commands/deploy/utils.ts new file mode 100644 index 000000000..d0db1ec08 --- /dev/null +++ b/src/cli/commands/deploy/utils.ts @@ -0,0 +1,33 @@ +import type { AgentCoreProjectSpec } from '../../../schema'; + +export type DeployMode = 'deploy' | 'dry-run' | 'diff'; + +export const DEFAULT_DEPLOY_ATTRS = { + runtime_count: 0, + memory_count: 0, + credential_count: 0, + evaluator_count: 0, + online_eval_count: 0, + gateway_count: 0, + gateway_target_count: 0, + policy_engine_count: 0, + policy_count: 0, + mode: 'deploy' as DeployMode, +}; + +export function computeDeployAttrs(projectSpec: Partial, mode: DeployMode) { + const gateways = projectSpec.agentCoreGateways ?? []; + const policyEngines = projectSpec.policyEngines ?? []; + return { + runtime_count: (projectSpec.runtimes ?? []).length, + memory_count: (projectSpec.memories ?? []).length, + credential_count: (projectSpec.credentials ?? []).length, + evaluator_count: (projectSpec.evaluators ?? []).length, + online_eval_count: (projectSpec.onlineEvalConfigs ?? []).length, + gateway_count: gateways.length, + gateway_target_count: gateways.reduce((sum, g) => sum + (g.targets ?? []).length, 0), + policy_engine_count: policyEngines.length, + policy_count: policyEngines.reduce((sum, pe) => sum + (pe.policies ?? []).length, 0), + mode, + }; +} diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index c35113e6d..44b41e7cd 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -63,24 +63,26 @@ async function resolveDeployedHandlers( result.onListMemoryRecords = async (memoryName, namespace, strategyId) => { const memory = memories.find(m => m.name === memoryName); if (!memory) return { success: false, error: `Memory "${memoryName}" not found in deployed state` }; - return listMemoryRecords({ + const res = await listMemoryRecords({ region: memory.region, memoryId: memory.memoryId, namespace, memoryStrategyId: strategyId, }); + return res.success ? res : { success: false as const, error: res.error.message }; }; result.onRetrieveMemoryRecords = async (memoryName, namespace, searchQuery, strategyId) => { const memory = memories.find(m => m.name === memoryName); if (!memory) return { success: false, error: `Memory "${memoryName}" not found in deployed state` }; - return retrieveMemoryRecords({ + const res = await retrieveMemoryRecords({ region: memory.region, memoryId: memory.memoryId, namespace, searchQuery, memoryStrategyId: strategyId, }); + return res.success ? res : { success: false as const, error: res.error.message }; }; } @@ -200,13 +202,14 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { const context = await loadDeployedProjectConfig(configIO); const resolved = resolveAgent(context, { runtime: agentName }); if (!resolved.success) return { success: false, error: resolved.error }; - return listTraces({ + const res = await listTraces({ region: resolved.agent.region, runtimeId: resolved.agent.runtimeId, agentName: resolved.agent.agentName, startTime, endTime, }); + return res.success ? res : { success: false as const, error: res.error.message }; } catch (err) { return { success: false, @@ -220,7 +223,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { const context = await loadDeployedProjectConfig(configIO); const resolved = resolveAgent(context, { runtime: agentName }); if (!resolved.success) return { success: false, error: resolved.error }; - return fetchTraceRecords({ + const res = await fetchTraceRecords({ region: resolved.agent.region, runtimeId: resolved.agent.runtimeId, traceId, @@ -228,6 +231,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { endTime, includeSpans: true, }); + return res.success ? res : { success: false as const, error: res.error.message }; } catch (err) { return { success: false, diff --git a/src/cli/commands/eval/command.tsx b/src/cli/commands/eval/command.tsx index 9dff195c4..4a086584b 100644 --- a/src/cli/commands/eval/command.tsx +++ b/src/cli/commands/eval/command.tsx @@ -1,3 +1,4 @@ +import { serializeResult } from '../../../lib'; import { getErrorMessage } from '../../errors'; import { handleListEvalRuns } from '../../operations/eval'; import { getResultsPath } from '../../operations/eval/storage'; @@ -27,13 +28,13 @@ export const registerEval = (program: Command) => { }); if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(JSON.stringify(serializeResult(result))); process.exit(result.success ? 0 : 1); return; } if (!result.success) { - render({result.error}); + render({result.error.message}); process.exit(1); } diff --git a/src/cli/commands/import/__tests__/idempotency.test.ts b/src/cli/commands/import/__tests__/idempotency.test.ts index 4b1938e28..7cf7648aa 100644 --- a/src/cli/commands/import/__tests__/idempotency.test.ts +++ b/src/cli/commands/import/__tests__/idempotency.test.ts @@ -10,6 +10,7 @@ // ── Import the function under test AFTER mocks ──────────────────────────────── import { handleImport } from '../actions'; import type { ParsedStarterToolkitConfig } from '../types'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // ── Hoisted mock fns (available inside vi.mock factories) ───────────────────── @@ -76,7 +77,20 @@ const { vi.mock('../../../../lib', () => ({ APP_DIR: 'app', ConfigIO: MockConfigIOClass, + NoProjectError: class NoProjectError extends Error { + constructor(msg?: string) { + super(msg ?? 'No agentcore project found'); + this.name = 'NoProjectError'; + } + }, + ValidationError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ValidationError'; + } + }, findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), })); vi.mock('../../../aws/account', () => ({ @@ -279,7 +293,7 @@ describe('Import Idempotency (Test Group 7)', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(true); + assert(result.success); expect(result.importedAgents).toContain('my-agent'); expect(result.importedMemories).toContain('my-memory'); @@ -393,7 +407,7 @@ describe('Import Idempotency (Test Group 7)', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(true); + assert(result.success); expect(result.importedAgents).toEqual([]); expect(result.importedMemories).toEqual([]); }); @@ -610,8 +624,8 @@ describe('Import Idempotency (Test Group 7)', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('No agents found'); + assert(!result.success); + expect(result.error.message).toContain('No agents found'); }); it('returns error when no project found', async () => { @@ -619,8 +633,8 @@ describe('Import Idempotency (Test Group 7)', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('No agentcore project found'); + assert(!result.success); + expect(result.error.message).toContain('No agentcore project found'); }); }); diff --git a/src/cli/commands/import/__tests__/import-evaluator.test.ts b/src/cli/commands/import/__tests__/import-evaluator.test.ts index 5e6fb5e96..1795ce4fe 100644 --- a/src/cli/commands/import/__tests__/import-evaluator.test.ts +++ b/src/cli/commands/import/__tests__/import-evaluator.test.ts @@ -210,6 +210,49 @@ describe('toEvaluatorSpec', () => { expect(result.tags).toBeUndefined(); }); + + it('forwards kmsKeyArn when present', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-kms', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-kms', + evaluatorName: 'kms_eval', + level: 'SESSION', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate', + ratingScale: { numerical: [{ value: 1, label: 'Low', definition: 'Low' }] }, + }, + }, + kmsKeyArn: 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012', + }; + + const result = toEvaluatorSpec(detail, 'kms_eval'); + + expect(result.kmsKeyArn).toBe('arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'); + }); + + it('omits kmsKeyArn when not present', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-no-kms', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-no-kms', + evaluatorName: 'no_kms_eval', + level: 'SESSION', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate', + ratingScale: { numerical: [{ value: 1, label: 'Low', definition: 'Low' }] }, + }, + }, + }; + + const result = toEvaluatorSpec(detail, 'no_kms_eval'); + + expect(result.kmsKeyArn).toBeUndefined(); + }); }); // ============================================================================ 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 183a6e63f..34803e8eb 100644 --- a/src/cli/commands/import/__tests__/import-gateway-flow.test.ts +++ b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts @@ -11,6 +11,7 @@ * - Non-READY gateway warning */ import { handleImportGateway } from '../import-gateway'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // ── Hoisted mock fns ──────────────────────────────────────────────────────── @@ -56,6 +57,12 @@ const { vi.mock('../../../../lib', () => ({ APP_DIR: 'app', ConfigIO: MockConfigIOClass, + NoProjectError: class NoProjectError extends Error { + constructor(msg?: string) { + super(msg ?? 'No agentcore project found'); + this.name = 'NoProjectError'; + } + }, findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), })); @@ -177,7 +184,7 @@ describe('handleImportGateway', () => { it('successfully imports a gateway with --arn', async () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(true); + assert(result.success); expect(result.resourceId).toBe(GATEWAY_ID); expect(result.resourceType).toBe('gateway'); expect(result.resourceName).toBe(GATEWAY_NAME); @@ -199,8 +206,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(false); - expect(result.error).toBe('Phase 2 failed'); + assert(!result.success); + expect(result.error.message).toBe('Phase 2 failed'); // First call = write merged config, second call = rollback expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); @@ -213,8 +220,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(false); - expect(result.error).toContain('Could not find logical ID'); + assert(!result.success); + expect(result.error.message).toContain('Could not find logical ID'); // First call = write merged config, second call = rollback expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); @@ -231,8 +238,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(false); - expect(result.error).toContain('already exists'); + assert(!result.success); + expect(result.error.message).toContain('already exists'); expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); }); @@ -254,7 +261,7 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(true); + assert(result.success); expect(result.resourceName).toBe('ExistingGateway'); expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1); expect(mockExecuteCdkImportPipeline).toHaveBeenCalled(); @@ -278,7 +285,7 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN, name: 'myCustomName' }); - expect(result.success).toBe(true); + assert(result.success); expect(result.resourceName).toBe('myCustomName'); expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1); expect(mockExecuteCdkImportPipeline).toHaveBeenCalled(); @@ -302,8 +309,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(false); - expect(result.error).toContain('already exists'); + assert(!result.success); + expect(result.error.message).toContain('already exists'); }); }); @@ -315,15 +322,15 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid name'); + assert(!result.success); + expect(result.error.message).toContain('Invalid name'); expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); }); it('uses --name override with original resourceName preserved', async () => { const result = await handleImportGateway({ arn: GATEWAY_ARN, name: 'myCustomName' }); - expect(result.success).toBe(true); + assert(result.success); expect(result.resourceName).toBe('myCustomName'); const writtenSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[0]![0]; @@ -343,7 +350,7 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({}); - expect(result.success).toBe(true); + assert(result.success); expect(result.resourceId).toBe(GATEWAY_ID); expect(mockGetGatewayDetail).toHaveBeenCalledWith({ region: REGION, gatewayId: GATEWAY_ID }); }); @@ -356,8 +363,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({}); - expect(result.success).toBe(false); - expect(result.error).toContain('Multiple gateways found'); + assert(!result.success); + expect(result.error.message).toContain('Multiple gateways found'); }); it('fails when no gateways exist and no --arn', async () => { @@ -365,8 +372,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({}); - expect(result.success).toBe(false); - expect(result.error).toContain('No gateways found'); + assert(!result.success); + expect(result.error.message).toContain('No gateways found'); }); }); @@ -399,7 +406,7 @@ describe('handleImportGateway', () => { onProgress: (msg: string) => progressMessages.push(msg), }); - expect(result.success).toBe(true); + assert(result.success); // Verify warning about unmapped targets expect(progressMessages.some(m => m.includes('1 target(s) could not be mapped'))).toBe(true); @@ -414,7 +421,7 @@ describe('handleImportGateway', () => { onProgress: (msg: string) => progressMessages.push(msg), }); - expect(result.success).toBe(true); + assert(result.success); expect(progressMessages.some(m => m.includes('CREATING') && m.includes('not READY'))).toBe(true); }); }); diff --git a/src/cli/commands/import/__tests__/import-no-deploy.test.ts b/src/cli/commands/import/__tests__/import-no-deploy.test.ts index f0b84303f..001c551be 100644 --- a/src/cli/commands/import/__tests__/import-no-deploy.test.ts +++ b/src/cli/commands/import/__tests__/import-no-deploy.test.ts @@ -5,6 +5,7 @@ * that were created but never deployed (no agent_id/memory_id in YAML). */ import { parseStarterToolkitYaml } from '../yaml-parser.js'; +import assert from 'node:assert'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -30,6 +31,12 @@ vi.mock('../../../../lib', () => ({ readDeployedState = mockReadDeployedState; writeDeployedState = mockWriteDeployedState; }, + NoProjectError: class NoProjectError extends Error { + constructor(msg?: string) { + super(msg ?? 'No agentcore project found'); + this.name = 'NoProjectError'; + } + }, findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), })); @@ -488,7 +495,7 @@ agents: onProgress: (msg: string) => progressMessages.push(msg), }); - expect(result.success).toBe(true); + assert(result.success); expect(result.importedAgents).toEqual([]); expect(result.importedMemories).toEqual([]); expect(result.stackName).toBeDefined(); @@ -645,6 +652,7 @@ agents: const { handleImport } = await import('../actions.js'); const result = await handleImport({ source: yamlPath }); + assert(result.success); expect(result.stackName).toBe('AgentCore-myproject-default'); }); }); @@ -724,7 +732,7 @@ agents: // No physical IDs means target resolution is skipped entirely. // The import succeeds -- config merge + source copy still happen. - expect(result.success).toBe(true); + assert(result.success); expect(result.importedAgents).toEqual([]); expect(result.importedMemories).toEqual([]); }); @@ -768,7 +776,7 @@ agents: const { handleImport } = await import('../actions.js'); const result = await handleImport({ source: yamlPath }); - expect(result.success).toBe(true); + assert(result.success); expect(result.importedAgents).toEqual([]); expect(result.importedMemories).toEqual([]); }); @@ -812,7 +820,7 @@ agents: const { handleImport } = await import('../actions.js'); const result = await handleImport({ source: yamlPath }); - expect(result.success).toBe(true); + assert(result.success); // No physical IDs means target is not written to disk expect(mockWriteAWSDeploymentTargets).not.toHaveBeenCalled(); // But the stackName should still be computed using 'default' fallback diff --git a/src/cli/commands/import/__tests__/import-runtime-handler.test.ts b/src/cli/commands/import/__tests__/import-runtime-handler.test.ts index b4fb7de90..d8926feb9 100644 --- a/src/cli/commands/import/__tests__/import-runtime-handler.test.ts +++ b/src/cli/commands/import/__tests__/import-runtime-handler.test.ts @@ -10,6 +10,7 @@ * - Fails when runtime name already exists in project */ import { handleImportRuntime } from '../import-runtime'; +import assert from 'node:assert'; import { afterEach, describe, expect, it, vi } from 'vitest'; // ── Mock dependencies ──────────────────────────────────────────────────────── @@ -25,7 +26,7 @@ const mockParseAndValidateArn = vi.fn(); const mockFindResourceInDeployedState = vi.fn(); const mockFailResult = vi.fn((...args: unknown[]) => ({ success: false, - error: args[1] as string, + error: new Error(args[1] as string), resourceType: args[2] as string, resourceName: args[3] as string, logPath: 'test.log', @@ -205,8 +206,9 @@ describe('handleImportRuntime', () => { }); expect(result.success).toBe(false); - expect(result.error).toContain('Could not determine entrypoint'); - expect(result.error).toContain('--entrypoint'); + assert(!result.success); + expect(result.error.message).toContain('Could not determine entrypoint'); + expect(result.error.message).toContain('--entrypoint'); }); it('fails with clear error when entryPoint is undefined', async () => { @@ -231,7 +233,8 @@ describe('handleImportRuntime', () => { }); expect(result.success).toBe(false); - expect(result.error).toContain('Could not determine entrypoint'); + assert(!result.success); + expect(result.error.message).toContain('Could not determine entrypoint'); }); it('fails with clear error when entryPoint is empty array', async () => { @@ -256,7 +259,8 @@ describe('handleImportRuntime', () => { }); expect(result.success).toBe(false); - expect(result.error).toContain('Could not determine entrypoint'); + assert(!result.success); + expect(result.error.message).toContain('Could not determine entrypoint'); }); it('uses --entrypoint flag when provided, bypassing auto-detection', async () => { @@ -369,7 +373,8 @@ describe('handleImportRuntime', () => { }); expect(result.success).toBe(false); - expect(result.error).toContain('Multiple runtimes found'); + assert(!result.success); + expect(result.error.message).toContain('Multiple runtimes found'); }); it('errors when no runtimes exist', async () => { @@ -382,7 +387,8 @@ describe('handleImportRuntime', () => { }); expect(result.success).toBe(false); - expect(result.error).toContain('No runtimes found'); + assert(!result.success); + expect(result.error.message).toContain('No runtimes found'); }); }); @@ -546,7 +552,8 @@ describe('handleImportRuntime', () => { }); expect(result.success).toBe(false); - expect(result.error).toContain('--code'); + assert(!result.success); + expect(result.error.message).toContain('--code'); }); it('fails when source path does not exist', async () => { @@ -574,7 +581,8 @@ describe('handleImportRuntime', () => { }); expect(result.success).toBe(false); - expect(result.error).toContain('does not exist'); + assert(!result.success); + expect(result.error.message).toContain('does not exist'); }); it('fails when runtime name already exists in project', async () => { @@ -606,7 +614,8 @@ describe('handleImportRuntime', () => { }); expect(result.success).toBe(false); - expect(result.error).toContain('already exists'); + assert(!result.success); + expect(result.error.message).toContain('already exists'); }); }); }); diff --git a/src/cli/commands/import/__tests__/jwt-authorizer.test.ts b/src/cli/commands/import/__tests__/jwt-authorizer.test.ts index 024c592df..1da1bb33a 100644 --- a/src/cli/commands/import/__tests__/jwt-authorizer.test.ts +++ b/src/cli/commands/import/__tests__/jwt-authorizer.test.ts @@ -27,7 +27,20 @@ vi.mock('../../../../lib', () => ({ readDeployedState = mockReadDeployedState; writeDeployedState = mockWriteDeployedState; }, + NoProjectError: class NoProjectError extends Error { + constructor(msg?: string) { + super(msg ?? 'No agentcore project found'); + this.name = 'NoProjectError'; + } + }, + ValidationError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ValidationError'; + } + }, findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), })); vi.mock('../../../aws/account', () => ({ diff --git a/src/cli/commands/import/__tests__/phase-failure-rollback.test.ts b/src/cli/commands/import/__tests__/phase-failure-rollback.test.ts index 149d65faf..f18f3cd71 100644 --- a/src/cli/commands/import/__tests__/phase-failure-rollback.test.ts +++ b/src/cli/commands/import/__tests__/phase-failure-rollback.test.ts @@ -7,6 +7,7 @@ */ import { handleImport } from '../actions'; import type { ParsedStarterToolkitConfig } from '../types'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // ── Hoisted mock fns ──────────────────────────────────────────────────────── @@ -73,7 +74,20 @@ const { vi.mock('../../../../lib', () => ({ APP_DIR: 'app', ConfigIO: MockConfigIOClass, + NoProjectError: class NoProjectError extends Error { + constructor(msg?: string) { + super(msg ?? 'No agentcore project found'); + this.name = 'NoProjectError'; + } + }, + ValidationError: class ValidationError extends Error { + constructor(msg: string) { + super(msg); + this.name = 'ValidationError'; + } + }, findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), })); vi.mock('../../../aws/account', () => ({ @@ -249,8 +263,8 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('Phase 1 failed'); + assert(!result.success); + expect(result.error.message).toContain('Phase 1 failed'); // First call = merge write, second call = rollback with original (empty) runtimes expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); @@ -265,8 +279,8 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('Phase 2 failed'); + assert(!result.success); + expect(result.error.message).toContain('Phase 2 failed'); expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); const rollbackData = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; @@ -280,8 +294,8 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('CDK build failed'); + assert(!result.success); + expect(result.error.message).toContain('CDK build failed'); expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); const rollbackData = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; @@ -294,7 +308,7 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(true); + assert(result.success); // Only one write: the merge write expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1); }); @@ -311,8 +325,8 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('No agents found'); + assert(!result.success); + expect(result.error.message).toContain('No agents found'); // Config was never written, so no rollback expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); }); @@ -338,8 +352,8 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('Could not read deployed template'); + assert(!result.success); + expect(result.error.message).toContain('Could not read deployed template'); expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); const rollbackData = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; diff --git a/src/cli/commands/import/actions.ts b/src/cli/commands/import/actions.ts index 71eb70f83..94323e30c 100644 --- a/src/cli/commands/import/actions.ts +++ b/src/cli/commands/import/actions.ts @@ -1,4 +1,4 @@ -import { APP_DIR, ConfigIO, findConfigRoot } from '../../../lib'; +import { APP_DIR, ConfigIO, NoProjectError, ValidationError, findConfigRoot, toError } from '../../../lib'; import type { AgentCoreProjectSpec, AgentCoreRegion, @@ -124,9 +124,10 @@ export async function handleImport(options: ImportOptions): Promise` first, then run import from inside the project.'; - logger.endStep('error', error); + const error = new NoProjectError( + 'No agentcore project found in the current directory.\nRun `agentcore create ` first, then run import from inside the project.' + ); + logger.endStep('error', error.message); logger.finalize(false); return { success: false, @@ -160,7 +161,7 @@ export async function handleImport(options: ImportOptions): Promise { console.log(`Log: ${result.logPath}`); } } else { - console.error(`\n\x1b[31m[error]${reset} Import failed: ${result.error}`); + console.error(`\n\x1b[31m[error]${reset} Import failed: ${result.error.message}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-evaluator.ts b/src/cli/commands/import/import-evaluator.ts index 7c6c8b0d2..914a50dfa 100644 --- a/src/cli/commands/import/import-evaluator.ts +++ b/src/cli/commands/import/import-evaluator.ts @@ -50,6 +50,7 @@ export function toEvaluatorSpec(detail: GetEvaluatorResult, localName: string): level, ...(detail.description && { description: detail.description }), config, + ...(detail.kmsKeyArn && { kmsKeyArn: detail.kmsKeyArn }), ...(detail.tags && Object.keys(detail.tags).length > 0 && { tags: detail.tags }), }; } @@ -151,7 +152,7 @@ export function registerImportEvaluator(importCmd: Command): void { console.log(` ID: ${result.resourceId}`); console.log(''); } else { - console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error.message}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts index 3c2384e03..5a7822210 100644 --- a/src/cli/commands/import/import-gateway.ts +++ b/src/cli/commands/import/import-gateway.ts @@ -1,3 +1,4 @@ +import { toError } from '../../../lib'; import type { AgentCoreGateway, AgentCoreGatewayTarget, @@ -612,7 +613,7 @@ export async function handleImportGateway(options: ImportResourceOptions): Promi logger.finalize(false); return { success: false, - error: pipelineResult.error, + error: new Error(pipelineResult.error ?? 'Pipeline failed'), resourceType: 'gateway', resourceName: localName, logPath: logger.getRelativeLogPath(), @@ -638,7 +639,7 @@ export async function handleImportGateway(options: ImportResourceOptions): Promi } return { success: false, - error: message, + error: toError(err), resourceType: 'gateway', resourceName: options.name ?? '', logPath: importCtx?.logger.getRelativeLogPath(), @@ -680,7 +681,7 @@ export function registerImportGateway(importCmd: Command): void { console.log(` agentcore fetch access ${ANSI.dim}Get gateway URL and token${ANSI.reset}`); console.log(''); } else { - console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error.message}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-memory.ts b/src/cli/commands/import/import-memory.ts index 2362d1353..02213c9f1 100644 --- a/src/cli/commands/import/import-memory.ts +++ b/src/cli/commands/import/import-memory.ts @@ -121,7 +121,7 @@ export function registerImportMemory(importCmd: Command): void { console.log(` ID: ${result.resourceId}`); console.log(''); } else { - console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error.message}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts index 99935ecdf..b43fa174a 100644 --- a/src/cli/commands/import/import-online-eval.ts +++ b/src/cli/commands/import/import-online-eval.ts @@ -209,7 +209,7 @@ export function registerImportOnlineEval(importCmd: Command): void { console.log(` ID: ${result.resourceId}`); console.log(''); } else { - console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error.message}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-runtime.ts b/src/cli/commands/import/import-runtime.ts index b1921d546..ff6ba0640 100644 --- a/src/cli/commands/import/import-runtime.ts +++ b/src/cli/commands/import/import-runtime.ts @@ -225,7 +225,7 @@ export function registerImportRuntime(importCmd: Command): void { console.log(` agentcore invoke ${ANSI.dim}Test your agent${ANSI.reset}`); console.log(''); } else { - console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error.message}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index 5829bbc6c..e29ada5ee 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -67,7 +67,7 @@ export function failResult( logger.finalize(false); return { success: false, - error, + error: new Error(error), resourceType, resourceName, logPath: logger.getRelativeLogPath(), diff --git a/src/cli/commands/import/resource-import.ts b/src/cli/commands/import/resource-import.ts index a21a160ec..ad543fd48 100644 --- a/src/cli/commands/import/resource-import.ts +++ b/src/cli/commands/import/resource-import.ts @@ -1,3 +1,4 @@ +import { toError } from '../../../lib'; import type { AgentCoreProjectSpec } from '../../../schema'; import { NAME_REGEX } from './constants'; import { executeCdkImportPipeline } from './import-pipeline'; @@ -228,7 +229,7 @@ export async function executeResourceImport( logger.finalize(false); return { success: false, - error: pipelineResult.error, + error: new Error(pipelineResult.error ?? 'Pipeline failed'), resourceType: descriptor.resourceType, resourceName: localName, logPath: logger.getRelativeLogPath(), @@ -254,7 +255,7 @@ export async function executeResourceImport( } return { success: false, - error: message, + error: toError(err), resourceType: descriptor.resourceType, resourceName: options.name ?? '', logPath: importCtx?.logger.getRelativeLogPath(), diff --git a/src/cli/commands/import/types.ts b/src/cli/commands/import/types.ts index 5d99c79c1..032f92793 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -1,3 +1,4 @@ +import type { Result } from '../../../lib/result'; import type { AgentCoreProjectSpec, AuthorizerConfig, @@ -89,27 +90,19 @@ export interface ResourceToImport { /** * Result of the import command. */ -export interface ImportResult { - success: boolean; - error?: string; +export type ImportResult = Result<{ projectSpec?: AgentCoreProjectSpec; importedAgents?: string[]; importedMemories?: string[]; stackName?: string; - logPath?: string; -} +}> & { logPath?: string }; /** * Result for single-resource import (runtime, memory, evaluator, etc.). */ -export interface ImportResourceResult { - success: boolean; - error?: string; - resourceType: ImportableResourceType; - resourceName: string; +export type ImportResourceResult = Result<{ resourceId?: string; - logPath?: string; -} +}> & { resourceType: ImportableResourceType; resourceName: string; logPath?: string }; /** * Options shared across import subcommands. diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index eb7aaaac2..ad21c7113 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -1,4 +1,4 @@ -import { ConfigIO } from '../../../lib'; +import { ConfigIO, ResourceNotFoundError, ValidationError } from '../../../lib'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../schema'; import { buildAguiRunInput, @@ -43,41 +43,58 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption // Resolve target const targetNames = Object.keys(deployedState.targets); if (targetNames.length === 0) { - return { success: false, error: 'No deployed targets found. Run `agentcore deploy` first.' }; + return { + success: false, + error: new ResourceNotFoundError('No deployed targets found. Run `agentcore deploy` first.'), + }; } const selectedTargetName = options.targetName ?? targetNames[0]!; if (options.targetName && !targetNames.includes(options.targetName)) { - return { success: false, error: `Target '${options.targetName}' not found. Available: ${targetNames.join(', ')}` }; + return { + success: false, + error: new ResourceNotFoundError( + `Target '${options.targetName}' not found. Available: ${targetNames.join(', ')}` + ), + }; } const targetState = deployedState.targets[selectedTargetName]; const targetConfig = awsTargets.find(t => t.name === selectedTargetName); if (!targetConfig) { - return { success: false, error: `Target config '${selectedTargetName}' not found in aws-targets` }; + return { + success: false, + error: new ResourceNotFoundError(`Target config '${selectedTargetName}' not found in aws-targets`), + }; } if (project.runtimes.length === 0) { - return { success: false, error: 'No agents defined in configuration' }; + return { success: false, error: new ValidationError('No agents defined in configuration') }; } // Resolve agent const agentNames = project.runtimes.map(a => a.name); if (!options.agentName && project.runtimes.length > 1) { - return { success: false, error: `Multiple runtimes found. Use --runtime to specify one: ${agentNames.join(', ')}` }; + return { + success: false, + error: new ValidationError(`Multiple runtimes found. Use --runtime to specify one: ${agentNames.join(', ')}`), + }; } const agentSpec = options.agentName ? project.runtimes.find(a => a.name === options.agentName) : project.runtimes[0]; if (options.agentName && !agentSpec) { - return { success: false, error: `Agent '${options.agentName}' not found. Available: ${agentNames.join(', ')}` }; + return { + success: false, + error: new ResourceNotFoundError(`Agent '${options.agentName}' not found. Available: ${agentNames.join(', ')}`), + }; } if (!agentSpec) { - return { success: false, error: 'No agents defined in configuration' }; + return { success: false, error: new ValidationError('No agents defined in configuration') }; } // Warn about VPC mode endpoint requirements @@ -91,7 +108,10 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption const agentState = targetState?.resources?.runtimes?.[agentSpec.name]; if (!agentState) { - return { success: false, error: `Agent '${agentSpec.name}' is not deployed to target '${selectedTargetName}'` }; + return { + success: false, + error: new ValidationError(`Agent '${agentSpec.name}' is not deployed to target '${selectedTargetName}'`), + }; } // Build config bundle baggage if a bundle is associated with this agent @@ -118,13 +138,18 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption } catch (err) { return { success: false, - error: `CUSTOM_JWT agent requires a bearer token. Auto-fetch failed: ${err instanceof Error ? err.message : String(err)}\nProvide one manually with --bearer-token.`, + error: new ValidationError( + `CUSTOM_JWT agent requires a bearer token. Auto-fetch failed: ${err instanceof Error ? err.message : String(err)}\nProvide one manually with --bearer-token.`, + { cause: err } + ), }; } } else { return { success: false, - error: `Agent '${agentSpec.name}' is configured for CUSTOM_JWT but no bearer token is available.\nEither provide --bearer-token or re-add the agent with --client-id and --client-secret to enable auto-fetch.`, + error: new ValidationError( + `Agent '${agentSpec.name}' is configured for CUSTOM_JWT but no bearer token is available.\nEither provide --bearer-token or re-add the agent with --client-id and --client-secret to enable auto-fetch.` + ), }; } } @@ -147,7 +172,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption }); const command = options.prompt; if (!command) { - return { success: false, error: '--exec requires a command (prompt)' }; + return { success: false, error: new ValidationError('--exec requires a command (prompt)') }; } logger.logPrompt(command, options.sessionId, options.userId); @@ -195,8 +220,18 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption logger.logResponse(stdout || stderr || `exit code: ${exitCode}`); if (options.json) { + if (exitCode === 0) { + return { + success: true, + agentName: agentSpec.name, + targetName: selectedTargetName, + response: JSON.stringify({ stdout, stderr, exitCode, status }), + logFilePath: logger.logFilePath, + }; + } return { - success: exitCode === 0, + success: false, + error: new Error(`Command exited with code ${exitCode}`), agentName: agentSpec.name, targetName: selectedTargetName, response: JSON.stringify({ stdout, stderr, exitCode, status }), @@ -207,9 +242,9 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (exitCode === undefined) { return { success: false, + error: new Error('Command stream ended without exit code'), agentName: agentSpec.name, targetName: selectedTargetName, - error: 'Command stream ended without exit code', logFilePath: logger.logFilePath, }; } @@ -217,9 +252,10 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (exitCode !== 0) { return { success: false, + error: new Error(`Command exited with code ${exitCode}${status === 'TIMED_OUT' ? ' (timed out)' : ''}`), agentName: agentSpec.name, targetName: selectedTargetName, - error: `Command exited with code ${exitCode}${status === 'TIMED_OUT' ? ' (timed out)' : ''}`, + response: JSON.stringify({ stdout, stderr, exitCode, status }), logFilePath: logger.logFilePath, }; } @@ -261,7 +297,9 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption } catch (err) { return { success: false, - error: `Failed to list MCP tools: ${err instanceof Error ? err.message : String(err)}`, + error: new Error(`Failed to list MCP tools: ${err instanceof Error ? err.message : String(err)}`, { + cause: err, + }), }; } } @@ -271,7 +309,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (!options.tool) { return { success: false, - error: 'MCP call-tool requires --tool . Use "list-tools" to see available tools.', + error: new Error('MCP call-tool requires --tool . Use "list-tools" to see available tools.'), }; } let args: Record = {}; @@ -279,7 +317,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption try { args = JSON.parse(options.input) as Record; } catch { - return { success: false, error: `Invalid JSON for --input: ${options.input}` }; + return { success: false, error: new ValidationError(`Invalid JSON for --input: ${options.input}`) }; } } try { @@ -295,7 +333,9 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption } catch (err) { return { success: false, - error: `Failed to call MCP tool: ${err instanceof Error ? err.message : String(err)}`, + error: new Error(`Failed to call MCP tool: ${err instanceof Error ? err.message : String(err)}`, { + cause: err, + }), }; } } @@ -303,14 +343,15 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (!options.prompt) { return { success: false, - error: - 'MCP agents require a command. Usage:\n agentcore invoke list-tools\n agentcore invoke call-tool --tool --input \'{"arg": "value"}\'', + error: new ValidationError( + 'MCP agents require a command. Usage:\n agentcore invoke list-tools\n agentcore invoke call-tool --tool --input \'{"arg": "value"}\'' + ), }; } } if (!options.prompt) { - return { success: false, error: 'No prompt provided. Usage: agentcore invoke "your prompt"' }; + return { success: false, error: new ValidationError('No prompt provided. Usage: agentcore invoke "your prompt"') }; } // A2A protocol handling — send JSON-RPC message/send via InvokeAgentRuntime @@ -343,7 +384,10 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption response, }; } catch (err) { - return { success: false, error: `A2A invoke failed: ${err instanceof Error ? err.message : String(err)}` }; + return { + success: false, + error: new Error(`A2A invoke failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err }), + }; } } @@ -388,8 +432,20 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption logger.logResponse(response); + if (hasError) { + return { + success: false, + error: new Error(response), + agentName: agentSpec.name, + targetName: selectedTargetName, + response, + sessionId: aguiResult.sessionId, + logFilePath: logger.logFilePath, + }; + } + return { - success: !hasError, + success: true, agentName: agentSpec.name, targetName: selectedTargetName, response, @@ -398,7 +454,10 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption }; } catch (err) { logger.logError(err, 'AGUI invoke failed'); - return { success: false, error: `AGUI invoke failed: ${err instanceof Error ? err.message : String(err)}` }; + return { + success: false, + error: new Error(`AGUI invoke failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err }), + }; } } diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index cc0cd1e35..03099a125 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -1,3 +1,4 @@ +import { serializeResult } from '../../../lib'; import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; @@ -55,7 +56,7 @@ async function handleInvokeCLI(options: InvokeOptions): Promise { } if (options.json) { - console.log(JSON.stringify(result)); + console.log(JSON.stringify(serializeResult(result))); } else if (options.stream) { // Streaming already wrote to stdout, just show session and log path if (result.sessionId) { @@ -70,7 +71,11 @@ async function handleInvokeCLI(options: InvokeOptions): Promise { if (result.success && result.response) { console.log(result.response); } else if (!result.success && result.error) { - console.error(result.error); + console.error(result.error.message); + } + if (result.sessionId) { + console.error(`\nSession: ${result.sessionId}`); + console.error(`To resume: agentcore invoke --session-id ${result.sessionId}`); } if (result.sessionId) { console.error(`\nSession: ${result.sessionId}`); diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts index 61401c332..86411214c 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -1,3 +1,5 @@ +import type { Result } from '../../../lib/result'; + export interface InvokeOptions { agentName?: string; targetName?: string; @@ -22,12 +24,10 @@ export interface InvokeOptions { bearerToken?: string; } -export interface InvokeResult { - success: boolean; +export type InvokeResult = Result & { + logFilePath?: string; agentName?: string; targetName?: string; response?: string; sessionId?: string; - error?: string; - logFilePath?: string; -} +}; diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 07d072320..1cd58c625 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -132,9 +132,9 @@ describe('resolveAgentContext', () => { const result = resolveAgentContext(context, {}); expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('Multiple runtimes found'); - expect(result.error).toContain('AgentA'); - expect(result.error).toContain('AgentB'); + expect(result.error.message).toContain('Multiple runtimes found'); + expect(result.error.message).toContain('AgentA'); + expect(result.error.message).toContain('AgentB'); } }); @@ -205,7 +205,7 @@ describe('resolveAgentContext', () => { const result = resolveAgentContext(makeContext(), { runtime: 'UnknownAgent' }); expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain("Runtime 'UnknownAgent' not found"); + expect(result.error.message).toContain("Runtime 'UnknownAgent' not found"); } }); @@ -230,7 +230,7 @@ describe('resolveAgentContext', () => { const result = resolveAgentContext(context, {}); expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('No runtimes defined'); + expect(result.error.message).toContain('No runtimes defined'); } }); @@ -249,7 +249,7 @@ describe('resolveAgentContext', () => { const result = resolveAgentContext(context, {}); expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('is not deployed'); + expect(result.error.message).toContain('is not deployed'); } }); }); diff --git a/src/cli/commands/logs/action.ts b/src/cli/commands/logs/action.ts index 72be2c864..b045b1c96 100644 --- a/src/cli/commands/logs/action.ts +++ b/src/cli/commands/logs/action.ts @@ -1,3 +1,5 @@ +import { ResourceNotFoundError, ValidationError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import { parseTimeString } from '../../../lib/utils'; import { searchLogs, streamLogs } from '../../aws/cloudwatch'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; @@ -17,11 +19,6 @@ export interface AgentContext { logGroupName: string; } -export interface LogsResult { - success: boolean; - error?: string; -} - /** * Detect whether to stream or search based on options */ @@ -49,10 +46,10 @@ export function formatLogLine(event: { timestamp: number; message: string }, jso export function resolveAgentContext( context: DeployedProjectConfig, options: LogsOptions -): { success: true; agentContext: AgentContext } | { success: false; error: string } { +): Result<{ agentContext: AgentContext }> { const result = resolveAgent(context, options); if (!result.success) { - return { success: false, error: result.error }; + return { success: false, error: new ResourceNotFoundError(result.error) }; } const { agent } = result; const endpointName = DEFAULT_ENDPOINT_NAME; @@ -73,12 +70,12 @@ export function resolveAgentContext( /** * Main logs handler */ -export async function handleLogs(options: LogsOptions): Promise { +export async function handleLogs(options: LogsOptions): Promise { // Validate level early if (options.level && !VALID_LEVELS.includes(options.level.toLowerCase())) { return { success: false, - error: `Invalid log level: "${options.level}". Valid levels: ${VALID_LEVELS.join(', ')}`, + error: new ValidationError(`Invalid log level: "${options.level}". Valid levels: ${VALID_LEVELS.join(', ')}`), }; } @@ -96,7 +93,7 @@ export async function handleLogs(options: LogsOptions): Promise { try { filterPattern = buildFilterPattern({ level: options.level, query: options.query }); } catch (err) { - return { success: false, error: (err as Error).message }; + return { success: false, error: err instanceof Error ? err : new Error(String(err)) }; } const mode = detectMode(options); @@ -143,7 +140,9 @@ export async function handleLogs(options: LogsOptions): Promise { if (errorName === 'ResourceNotFoundException') { return { success: false, - error: `No logs found for agent '${agentContext.agentName}'. Has the agent been invoked?`, + error: new ResourceNotFoundError( + `No logs found for agent '${agentContext.agentName}'. Has the agent been invoked?` + ), }; } diff --git a/src/cli/commands/logs/command.tsx b/src/cli/commands/logs/command.tsx index 05649887d..ba6d1ef21 100644 --- a/src/cli/commands/logs/command.tsx +++ b/src/cli/commands/logs/command.tsx @@ -34,7 +34,7 @@ export const registerLogs = (program: Command) => { const result = await handleLogs(cliOptions); if (!result.success) { - render({result.error}); + render({result.error.message}); process.exit(1); } } catch (error) { @@ -59,7 +59,7 @@ export const registerLogs = (program: Command) => { const result = await handleLogsEval(cliOptions); if (!result.success) { - render({result.error}); + render({result.error.message}); process.exit(1); } } catch (error) { diff --git a/src/cli/commands/pause/command.tsx b/src/cli/commands/pause/command.tsx index e99e266aa..c60f87158 100644 --- a/src/cli/commands/pause/command.tsx +++ b/src/cli/commands/pause/command.tsx @@ -1,4 +1,4 @@ -import { ConfigIO } from '../../../lib'; +import { ConfigIO, serializeResult } from '../../../lib'; import { listABTests, updateABTest } from '../../aws/agentcore-ab-tests'; import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; import { getErrorMessage } from '../../errors'; @@ -52,12 +52,12 @@ function registerOnlineEvalSubcommand(parent: Command, action: 'pause' | 'resume const result = await handlePauseResume(options, action); if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(JSON.stringify(serializeResult(result))); } else if (result.success) { const displayName = cliOptions.arn ? result.configId : name; console.log(`${pastTense} online eval config "${displayName}" (status: ${result.executionStatus})`); } else { - render({result.error}); + render({result.error.message}); } process.exit(result.success ? 0 : 1); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index da1951d19..369a323d7 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -1,4 +1,4 @@ -import { ConfigIO } from '../../../lib'; +import { ConfigIO, serializeResult, toError } from '../../../lib'; import { getErrorMessage } from '../../errors'; import { runCliCommand } from '../../telemetry/cli-command-run.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; @@ -49,7 +49,7 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise { validateRemoveAllOptions(options); await runCliCommand('remove.all', !!options.json, async () => { const result = await handleRemoveAll(options); - if (!result.success) throw new Error(result.error); - console.log(JSON.stringify(result)); + if (!result.success) throw result.error; + console.log(JSON.stringify(serializeResult(result))); return {}; }); } diff --git a/src/cli/commands/remove/types.ts b/src/cli/commands/remove/types.ts index 9a30f5e24..b45c3ba4a 100644 --- a/src/cli/commands/remove/types.ts +++ b/src/cli/commands/remove/types.ts @@ -1,3 +1,5 @@ +import type { Result } from '../../../lib/result'; + export type ResourceType = | 'agent' | 'gateway' @@ -25,11 +27,9 @@ export interface RemoveAllOptions { json?: boolean; } -export interface RemoveResult { - success: boolean; +export type RemoveResult = Result<{ resourceType?: ResourceType; resourceName?: string; message?: string; note?: string; - error?: string; -} +}>; diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index e5ba8ca59..09b18358d 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -1,3 +1,4 @@ +import { serializeResult } from '../../../lib'; import type { RecommendationType } from '../../aws/agentcore-recommendation'; import { getErrorMessage } from '../../errors'; import { handleRunEval } from '../../operations/eval'; @@ -25,7 +26,7 @@ const RECOMMENDATION_TYPE_MAP: Record = { }; function formatRunOutput(result: Awaited>): void { - if (!result.run) return; + if (!result.success) return; const { run } = result; const date = new Date(run.timestamp).toLocaleString([], { @@ -56,7 +57,7 @@ function formatRunOutput(result: Awaited>): voi for (const r of run.results) { const score = r.aggregateScore.toFixed(2); - const errors = r.sessionScores.filter(s => s.errorMessage).length; + const errors = r.sessionScores.filter((s: { errorMessage?: string }) => s.errorMessage).length; const errorSuffix = errors > 0 ? ` (${errors} errors)` : ''; console.log(` ${r.evaluator}: ${score}${errorSuffix}`); } @@ -146,12 +147,12 @@ export const registerRun = (program: Command) => { const result = await handleRunEval(options); if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(JSON.stringify(serializeResult(result))); } else if (result.success) { formatRunOutput(result); } else { formatRunOutput(result); - render({result.error}); + render({result.error.message}); } process.exit(result.success ? 0 : 1); @@ -240,11 +241,11 @@ export const registerRun = (program: Command) => { } if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(JSON.stringify(serializeResult(result))); } else if (result.success) { formatBatchEvalOutput(result); } else { - render({result.error}); + render({result.error.message}); if (result.logFilePath) { console.error(`\nLog: ${result.logFilePath}`); } @@ -401,9 +402,9 @@ export const registerRun = (program: Command) => { if (!result.success) { if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(JSON.stringify(serializeResult(result))); } else { - render({result.error}); + render({result.error.message}); if (result.logFilePath) { console.error(`\nLog: ${result.logFilePath}`); } @@ -428,7 +429,7 @@ export const registerRun = (program: Command) => { } if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(JSON.stringify(serializeResult(result))); } else { console.log(`\nRecommendation ID: ${result.recommendationId}`); @@ -467,7 +468,7 @@ export const registerRun = (program: Command) => { ); console.log(`Local config for "${cliOptions.bundleName}" has been updated to match.`); } else { - console.log(`\nCould not sync config bundle: ${applyResult.error}`); + console.log(`\nCould not sync config bundle: ${applyResult.error.message}`); } } catch { // Non-fatal — user can manually sync diff --git a/src/cli/commands/status/__tests__/action.test.ts b/src/cli/commands/status/__tests__/action.test.ts index 1c3a089dc..f8c94c6bc 100644 --- a/src/cli/commands/status/__tests__/action.test.ts +++ b/src/cli/commands/status/__tests__/action.test.ts @@ -1,7 +1,8 @@ import type { AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema/index.js'; import { computeResourceStatuses, handleProjectStatus } from '../action.js'; -import type { StatusContext } from '../action.js'; +import type { ResourceStatusEntry, StatusContext } from '../action.js'; import { buildRuntimeInvocationUrl } from '../constants.js'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockGetAgentRuntimeStatus = vi.fn(); @@ -508,9 +509,11 @@ describe('handleProjectStatus — live enrichment', () => { const result = await handleProjectStatus(makeContext()); - expect(result.success).toBe(true); + assert(result.success); - const evalEntry = result.resources.find(r => r.resourceType === 'evaluator' && r.name === 'MyEval'); + const evalEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'evaluator' && r.name === 'MyEval' + ); expect(evalEntry).toBeDefined(); expect(evalEntry!.detail).toContain('ACTIVE'); @@ -536,9 +539,11 @@ describe('handleProjectStatus — live enrichment', () => { const result = await handleProjectStatus(makeContext()); - expect(result.success).toBe(true); + assert(result.success); - const configEntry = result.resources.find(r => r.resourceType === 'online-eval' && r.name === 'MyConfig'); + const configEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'online-eval' && r.name === 'MyConfig' + ); expect(configEntry).toBeDefined(); expect(configEntry!.detail).toContain('ACTIVE'); expect(configEntry!.detail).toContain('ENABLED'); @@ -560,9 +565,11 @@ describe('handleProjectStatus — live enrichment', () => { const result = await handleProjectStatus(makeContext()); - expect(result.success).toBe(true); + assert(result.success); - const evalEntry = result.resources.find(r => r.resourceType === 'evaluator' && r.name === 'MyEval'); + const evalEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'evaluator' && r.name === 'MyEval' + ); expect(evalEntry).toBeDefined(); expect(evalEntry!.error).toBe('AccessDenied'); }); @@ -578,9 +585,11 @@ describe('handleProjectStatus — live enrichment', () => { const result = await handleProjectStatus(makeContext()); - expect(result.success).toBe(true); + assert(result.success); - const configEntry = result.resources.find(r => r.resourceType === 'online-eval' && r.name === 'MyConfig'); + const configEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'online-eval' && r.name === 'MyConfig' + ); expect(configEntry).toBeDefined(); expect(configEntry!.error).toBe('ResourceNotFound'); }); @@ -624,9 +633,11 @@ describe('handleProjectStatus — live enrichment', () => { const result = await handleProjectStatus(ctx); - expect(result.success).toBe(true); + assert(result.success); - const evalEntry = result.resources.find(r => r.resourceType === 'evaluator' && r.name === 'MyEval'); + const evalEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'evaluator' && r.name === 'MyEval' + ); expect(evalEntry).toBeDefined(); expect(evalEntry!.deploymentState).toBe('local-only'); expect(mockGetEvaluator).not.toHaveBeenCalled(); @@ -697,8 +708,10 @@ describe('handleProjectStatus — invocation URL enrichment', () => { const result = await handleProjectStatus(ctx); - expect(result.success).toBe(true); - const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'MyAgent'); + assert(result.success); + const agentEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'agent' && r.name === 'MyAgent' + ); expect(agentEntry).toBeDefined(); expect(agentEntry!.invocationUrl).toBe( `https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/${encodeURIComponent(runtimeArn)}/invocations` @@ -723,8 +736,10 @@ describe('handleProjectStatus — invocation URL enrichment', () => { const result = await handleProjectStatus(ctx); - expect(result.success).toBe(true); - const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'LocalAgent'); + assert(result.success); + const agentEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'agent' && r.name === 'LocalAgent' + ); expect(agentEntry).toBeDefined(); expect(agentEntry!.invocationUrl).toBeUndefined(); }); @@ -758,8 +773,10 @@ describe('handleProjectStatus — invocation URL enrichment', () => { const result = await handleProjectStatus(ctx); - expect(result.success).toBe(true); - const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'FailAgent'); + assert(result.success); + const agentEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'agent' && r.name === 'FailAgent' + ); expect(agentEntry).toBeDefined(); expect(agentEntry!.error).toBe('Timeout'); expect(agentEntry!.invocationUrl).toBe( @@ -798,8 +815,10 @@ describe('handleProjectStatus — invocation URL enrichment', () => { const result = await handleProjectStatus(ctx); - expect(result.success).toBe(true); - const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'OldAgent'); + assert(result.success); + const agentEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'agent' && r.name === 'OldAgent' + ); expect(agentEntry).toBeDefined(); expect(agentEntry!.deploymentState).toBe('pending-removal'); expect(agentEntry!.invocationUrl).toBeUndefined(); diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 271b05bc9..e821b1f32 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -1,4 +1,5 @@ -import { ConfigIO } from '../../../lib'; +import { ConfigIO, ResourceNotFoundError, toError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedResourceState, DeployedState } from '../../../schema'; import { getAgentRuntimeStatus } from '../../aws'; import { getEvaluator, getOnlineEvaluationConfig } from '../../aws/agentcore-control'; @@ -32,15 +33,10 @@ export interface ResourceStatusEntry { invocationUrl?: string; } -export interface ProjectStatusResult { - success: boolean; - projectName: string; - targetName: string; +export type ProjectStatusResult = Result<{ targetRegion?: string; resources: ResourceStatusEntry[]; - error?: string; - logPath?: string; -} +}> & { projectName?: string; targetName?: string; logPath?: string; resources?: ResourceStatusEntry[] }; export interface StatusContext { project: AgentCoreProjectSpec; @@ -48,14 +44,11 @@ export interface StatusContext { awsTargets: AwsDeploymentTargets; } -export interface RuntimeLookupResult { - success: boolean; +export type RuntimeLookupResult = Result<{ targetName?: string; runtimeId?: string; runtimeStatus?: string; - error?: string; - logPath?: string; -} +}> & { logPath?: string }; /** * Loads configuration required for status check. @@ -333,10 +326,10 @@ export async function handleProjectStatus( logger.finalize(false); return { success: false, + error: new Error(error), projectName: project.name, targetName: options.targetName, resources: [], - error, logPath: logger.getRelativeLogPath(), }; } @@ -504,7 +497,7 @@ export async function handleRuntimeLookup( const error = 'No deployment targets found. Run `agentcore create` first.'; logger.endStep('error', error); logger.finalize(false); - return { success: false, error, logPath: logger.getRelativeLogPath() }; + return { success: false, error: new ResourceNotFoundError(error), logPath: logger.getRelativeLogPath() }; } const selectedTargetName = options.targetName ?? targetNames[0]!; @@ -513,7 +506,7 @@ export async function handleRuntimeLookup( const error = `Target '${options.targetName}' not found. Available: ${targetNames.join(', ')}`; logger.endStep('error', error); logger.finalize(false); - return { success: false, error, logPath: logger.getRelativeLogPath() }; + return { success: false, error: new ResourceNotFoundError(error), logPath: logger.getRelativeLogPath() }; } const targetConfig = awsTargets.find(target => target.name === selectedTargetName); @@ -522,7 +515,7 @@ export async function handleRuntimeLookup( const error = `Target config '${selectedTargetName}' not found in aws-targets`; logger.endStep('error', error); logger.finalize(false); - return { success: false, error, logPath: logger.getRelativeLogPath() }; + return { success: false, error: new ResourceNotFoundError(error), logPath: logger.getRelativeLogPath() }; } logger.log(`Target: ${selectedTargetName} (${targetConfig.region})`); @@ -550,6 +543,6 @@ export async function handleRuntimeLookup( const errorMsg = getErrorMessage(error); logger.endStep('error', errorMsg); logger.finalize(false); - return { success: false, error: errorMsg, logPath: logger.getRelativeLogPath() }; + return { success: false, error: toError(error), logPath: logger.getRelativeLogPath() }; } } diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index 506ad10ec..a155d71f0 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -1,3 +1,4 @@ +import { serializeResult } from '../../../lib'; import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; @@ -100,12 +101,12 @@ export const registerStatus = (program: Command) => { }); if (cliOptions.json) { - console.log(JSON.stringify(result, null, 2)); + console.log(JSON.stringify(serializeResult(result), null, 2)); return; } if (!result.success) { - render({result.error}); + render({result.error.message}); return; } @@ -126,13 +127,17 @@ export const registerStatus = (program: Command) => { }); if (cliOptions.json) { - const filtered = filterResources(result.resources, cliOptions); - console.log(JSON.stringify({ ...result, resources: filtered }, null, 2)); + if (result.success) { + const filtered = filterResources(result.resources, cliOptions); + console.log(JSON.stringify({ ...result, resources: filtered }, null, 2)); + } else { + console.log(JSON.stringify(serializeResult(result), null, 2)); + } return; } if (!result.success) { - render({result.error}); + render({result.error.message}); return; } @@ -153,7 +158,7 @@ export const registerStatus = (program: Command) => { render( - AgentCore Status (target: {result.targetName || 'No target configured'} + AgentCore Status (target: {result.targetName ?? 'No target configured'} {result.targetRegion ? `, ${result.targetRegion}` : ''}) diff --git a/src/cli/commands/traces/action.ts b/src/cli/commands/traces/action.ts index d761cd4b7..c69cc1853 100644 --- a/src/cli/commands/traces/action.ts +++ b/src/cli/commands/traces/action.ts @@ -1,17 +1,16 @@ +import { ResourceNotFoundError, ValidationError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import { parseTimeString } from '../../../lib/utils'; import type { DeployedProjectConfig } from '../../operations/resolve-agent'; import { resolveAgent } from '../../operations/resolve-agent'; import { buildTraceConsoleUrl, getTrace, listTraces } from '../../operations/traces'; import type { TracesGetOptions, TracesListOptions } from './types'; -export interface TracesListResult { - success: boolean; +export type TracesListResult = Result<{ agentName?: string; targetName?: string; - consoleUrl?: string; - traces?: { traceId: string; timestamp: string; sessionId?: string }[]; - error?: string; -} + traces: { traceId: string; timestamp: string; sessionId?: string }[]; +}> & { consoleUrl?: string }; export async function handleTracesList( context: DeployedProjectConfig, @@ -19,7 +18,7 @@ export async function handleTracesList( ): Promise { const resolved = resolveAgent(context, options); if (!resolved.success) { - return { success: false, error: resolved.error }; + return { success: false, error: new ResourceNotFoundError(resolved.error) }; } const { agent } = resolved; @@ -33,7 +32,7 @@ export async function handleTracesList( const limit = options.limit ? parseInt(options.limit, 10) : 20; if (isNaN(limit)) { - return { success: false, error: '--limit must be a number' }; + return { success: false, error: new ValidationError('--limit must be a number') }; } // Parse time options @@ -68,14 +67,11 @@ export async function handleTracesList( }; } -export interface TracesGetResult { - success: boolean; +export type TracesGetResult = Result<{ agentName?: string; targetName?: string; - consoleUrl?: string; filePath?: string; - error?: string; -} +}> & { consoleUrl?: string }; export async function handleTracesGet( context: DeployedProjectConfig, @@ -84,7 +80,7 @@ export async function handleTracesGet( ): Promise { const resolved = resolveAgent(context, options); if (!resolved.success) { - return { success: false, error: resolved.error }; + return { success: false, error: new ResourceNotFoundError(resolved.error) }; } const { agent } = resolved; diff --git a/src/cli/commands/traces/command.tsx b/src/cli/commands/traces/command.tsx index 0222ce419..576a686b3 100644 --- a/src/cli/commands/traces/command.tsx +++ b/src/cli/commands/traces/command.tsx @@ -37,7 +37,7 @@ export const registerTraces = (program: Command) => { if (!result.success) { render( - Error: {result.error} + Error: {result.error.message} {result.consoleUrl && Console: {result.consoleUrl}} ); @@ -109,7 +109,7 @@ export const registerTraces = (program: Command) => { if (!result.success) { render( - Error: {result.error} + Error: {result.error.message} {result.consoleUrl && Console: {result.consoleUrl}} ); diff --git a/src/cli/commands/validate/__tests__/action.test.ts b/src/cli/commands/validate/__tests__/action.test.ts index d6774f252..4e8714014 100644 --- a/src/cli/commands/validate/__tests__/action.test.ts +++ b/src/cli/commands/validate/__tests__/action.test.ts @@ -1,4 +1,5 @@ import { handleValidate } from '../action.js'; +import assert from 'node:assert'; import { afterEach, describe, expect, it, vi } from 'vitest'; const { @@ -74,7 +75,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); expect(result.success).toBe(false); - expect(result.error).toContain('No agentcore project found'); + assert(!result.success); + expect(result.error.message).toContain('No agentcore project found'); }); it('returns success when all configs are valid', async () => { @@ -95,7 +97,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); expect(result.success).toBe(false); - expect(result.error).toContain('invalid project'); + assert(!result.success); + expect(result.error.message).toContain('invalid project'); }); it('returns error when AWS targets fails', async () => { @@ -106,7 +109,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); expect(result.success).toBe(false); - expect(result.error).toContain('bad targets'); + assert(!result.success); + expect(result.error.message).toContain('bad targets'); }); it('validates state file when it exists', async () => { @@ -132,7 +136,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); expect(result.success).toBe(false); - expect(result.error).toContain('bad state'); + assert(!result.success); + expect(result.error.message).toContain('bad state'); }); it('uses custom directory when provided', async () => { @@ -155,7 +160,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); expect(result.success).toBe(false); - expect(result.error).toBe('field "name" is required'); + assert(!result.success); + expect(result.error.message).toBe('field "name" is required'); }); it('formats ConfigParseError with cause', async () => { @@ -166,8 +172,9 @@ describe('handleValidate', () => { const result = await handleValidate({}); expect(result.success).toBe(false); - expect(result.error).toContain('Invalid JSON in agentcore.json'); - expect(result.error).toContain('Unexpected token'); + assert(!result.success); + expect(result.error.message).toContain('Invalid JSON in agentcore.json'); + expect(result.error.message).toContain('Unexpected token'); }); it('formats ConfigReadError with cause', async () => { @@ -180,8 +187,9 @@ describe('handleValidate', () => { const result = await handleValidate({}); expect(result.success).toBe(false); - expect(result.error).toContain('Failed to read agentcore.json'); - expect(result.error).toContain('EACCES'); + assert(!result.success); + expect(result.error.message).toContain('Failed to read agentcore.json'); + expect(result.error.message).toContain('EACCES'); }); it('formats ConfigNotFoundError with file name', async () => { @@ -192,7 +200,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); expect(result.success).toBe(false); - expect(result.error).toBe('Required file not found: agentcore.json'); + assert(!result.success); + expect(result.error.message).toBe('Required file not found: agentcore.json'); }); it('formats non-Error values as strings', async () => { @@ -202,6 +211,7 @@ describe('handleValidate', () => { const result = await handleValidate({}); expect(result.success).toBe(false); - expect(result.error).toBe('string error'); + assert(!result.success); + expect(result.error.message).toBe('string error'); }); }); diff --git a/src/cli/commands/validate/action.ts b/src/cli/commands/validate/action.ts index 572f5cf7d..b6d12566b 100644 --- a/src/cli/commands/validate/action.ts +++ b/src/cli/commands/validate/action.ts @@ -7,21 +7,17 @@ import { NoProjectError, findConfigRoot, } from '../../../lib'; +import type { Result } from '../../../lib/result'; export interface ValidateOptions { directory?: string; } -export interface ValidateResult { - success: boolean; - error?: string; -} - /** * Validates all AgentCore schema files in the project. * Returns a binary success/fail result with an error message if validation fails. */ -export async function handleValidate(options: ValidateOptions): Promise { +export async function handleValidate(options: ValidateOptions): Promise { const baseDir = options.directory ?? process.cwd(); // Check if project exists @@ -29,7 +25,7 @@ export async function handleValidate(options: ValidateOptions): Promise { render(Valid); process.exit(0); } else { - render({result.error}); + render({result.error.message}); process.exit(1); } }); diff --git a/src/cli/commands/validate/index.ts b/src/cli/commands/validate/index.ts index cecbb46ac..067c35e56 100644 --- a/src/cli/commands/validate/index.ts +++ b/src/cli/commands/validate/index.ts @@ -1,2 +1,2 @@ export { registerValidate } from './command'; -export { handleValidate, type ValidateOptions, type ValidateResult } from './action'; +export { handleValidate, type ValidateOptions } from './action'; diff --git a/src/cli/errors.ts b/src/cli/errors.ts index 9e7e9397d..99c59c051 100644 --- a/src/cli/errors.ts +++ b/src/cli/errors.ts @@ -1,13 +1,3 @@ -/** - * Error thrown when an agent with the same name already exists. - */ -export class AgentAlreadyExistsError extends Error { - constructor(agentName: string) { - super(`An agent named "${agentName}" already exists in the schema.`); - this.name = 'AgentAlreadyExistsError'; - } -} - /** * Converts an unknown error to a string message. * Handles Error instances and other thrown values consistently. diff --git a/src/cli/operations/agent/generate/write-agent-to-project.ts b/src/cli/operations/agent/generate/write-agent-to-project.ts index bf48ddf9d..38c89fd85 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -1,7 +1,6 @@ -import { ConfigIO, requireConfigRoot } from '../../../../lib'; +import { AgentAlreadyExistsError, ConfigIO, requireConfigRoot } from '../../../../lib'; import type { AgentCoreProjectSpec } from '../../../../schema'; import { SCHEMA_VERSION } from '../../../constants'; -import { AgentAlreadyExistsError } from '../../../errors'; import type { CredentialStrategy } from '../../../primitives/CredentialPrimitive'; import type { GenerateConfig } from '../../../tui/screens/generate/types'; import { mapGenerateConfigToAgent, mapGenerateInputToMemories, mapModelProviderToCredentials } from './schema-mapper'; diff --git a/src/cli/operations/agent/import/index.ts b/src/cli/operations/agent/import/index.ts index e49d9e3c0..25fb6299f 100644 --- a/src/cli/operations/agent/import/index.ts +++ b/src/cli/operations/agent/import/index.ts @@ -3,10 +3,9 @@ * Provides executeImportAgent() as the shared handler called by both * the TUI hook (useAddAgent) and the non-interactive CLI (AgentPrimitive). */ -import { APP_DIR, ConfigIO } from '../../../../lib'; +import { APP_DIR, ConfigIO, toError } from '../../../../lib'; import type { RuntimeAuthorizerType, SDKFramework } from '../../../../schema'; import { getBedrockAgentConfig } from '../../../aws/bedrock-import'; -import { getErrorMessage } from '../../../errors'; import type { JwtConfigOptions } from '../../../primitives/auth-utils'; import { createManagedOAuthCredential } from '../../../primitives/auth-utils'; import type { AddResult } from '../../../primitives/types'; @@ -120,6 +119,6 @@ export async function executeImportAgent( return { success: true, agentName: name, agentPath }; } catch (err) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: toError(err) }; } } diff --git a/src/cli/operations/deploy/__tests__/post-deploy-observability.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-observability.test.ts index ba069f29e..8fc549624 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-observability.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-observability.test.ts @@ -70,7 +70,7 @@ describe('setupTransactionSearch', () => { }); it('propagates error from enableTransactionSearch', async () => { - mockEnableTransactionSearch.mockResolvedValue({ success: false, error: 'Insufficient permissions' }); + mockEnableTransactionSearch.mockResolvedValue({ success: false, error: new Error('Insufficient permissions') }); const result = await setupTransactionSearch({ region: 'us-east-1', @@ -78,7 +78,10 @@ describe('setupTransactionSearch', () => { agentNames: ['agent-1'], }); - expect(result).toEqual({ success: false, error: 'Insufficient permissions' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe('Insufficient permissions'); + } }); it('triggers when hasGateways is true and agentNames is empty', async () => { diff --git a/src/cli/operations/deploy/index.ts b/src/cli/operations/deploy/index.ts index 332f0ca2d..93cdb6353 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -39,11 +39,10 @@ export { type DeployedTarget, type DiscoverDeployedResult, type DestroyTargetOptions, - type StackTeardownResult, } from './teardown'; // Post-deploy observability setup -export { setupTransactionSearch, type TransactionSearchSetupResult } from './post-deploy-observability'; +export { setupTransactionSearch } from './post-deploy-observability'; // Post-deploy HTTP gateways export { diff --git a/src/cli/operations/deploy/post-deploy-observability.ts b/src/cli/operations/deploy/post-deploy-observability.ts index 0616a65dc..4ee613c42 100644 --- a/src/cli/operations/deploy/post-deploy-observability.ts +++ b/src/cli/operations/deploy/post-deploy-observability.ts @@ -1,3 +1,4 @@ +import type { Result } from '../../../lib/result'; import { readGlobalConfigSync } from '../../../lib/schemas/io/global-config'; import { enableTransactionSearch } from '../../aws/transaction-search'; @@ -8,11 +9,6 @@ export interface TransactionSearchSetupOptions { hasGateways?: boolean; } -export interface TransactionSearchSetupResult { - success: boolean; - error?: string; -} - /** * Post-deploy step: enable CloudWatch Transaction Search (Application Signals + * resource policy + CloudWatchLogs destination + 100% indexing). @@ -22,9 +18,7 @@ export interface TransactionSearchSetupResult { * * This is a non-blocking best-effort operation — failures do not fail the deploy. */ -export async function setupTransactionSearch( - options: TransactionSearchSetupOptions -): Promise { +export async function setupTransactionSearch(options: TransactionSearchSetupOptions): Promise { const { region, accountId, agentNames } = options; if (agentNames.length === 0 && !options.hasGateways) { diff --git a/src/cli/operations/deploy/teardown.ts b/src/cli/operations/deploy/teardown.ts index 2e38f2576..b7c39c49b 100644 --- a/src/cli/operations/deploy/teardown.ts +++ b/src/cli/operations/deploy/teardown.ts @@ -1,4 +1,5 @@ import { CONFIG_DIR, ConfigIO } from '../../../lib'; +import type { Result } from '../../../lib/result'; import type { AwsDeploymentTarget } from '../../../schema'; import { withTargetRegion } from '../../aws'; import { deleteConfigurationBundle } from '../../aws/agentcore-config-bundles'; @@ -99,16 +100,11 @@ export function getCdkProjectDir(cwd?: string): string { return join(baseDir, CONFIG_DIR, 'cdk'); } -export interface StackTeardownResult { - success: boolean; - error?: string; -} - /** * Perform full stack teardown for a target: destroy CloudFormation stack, * remove deployed-state entry, and remove the target from aws-targets.json. */ -export async function performStackTeardown(targetName: string): Promise { +export async function performStackTeardown(targetName: string): Promise { const cdkProjectDir = getCdkProjectDir(); const configIO = new ConfigIO(); diff --git a/src/cli/operations/eval/__tests__/list-eval-runs.test.ts b/src/cli/operations/eval/__tests__/list-eval-runs.test.ts index c9a71a8cf..1c1c48f71 100644 --- a/src/cli/operations/eval/__tests__/list-eval-runs.test.ts +++ b/src/cli/operations/eval/__tests__/list-eval-runs.test.ts @@ -1,5 +1,6 @@ import { handleListEvalRuns } from '../list-eval-runs.js'; import type { EvalRunResult } from '../types.js'; +import assert from 'node:assert'; import { afterEach, describe, expect, it, vi } from 'vitest'; const mockListEvalRuns = vi.fn(); @@ -28,7 +29,7 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({}); - expect(result.success).toBe(true); + assert(result.success); expect(result.runs).toHaveLength(2); }); @@ -42,9 +43,9 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({ agent: 'agent-a' }); - expect(result.success).toBe(true); + assert(result.success); expect(result.runs).toHaveLength(2); - expect(result.runs!.every(r => r.agent === 'agent-a')).toBe(true); + expect(result.runs.every((r: EvalRunResult) => r.agent === 'agent-a')).toBe(true); }); it('limits the number of results', () => { @@ -57,7 +58,7 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({ limit: 2 }); - expect(result.success).toBe(true); + assert(result.success); expect(result.runs).toHaveLength(2); }); @@ -72,9 +73,10 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({ agent: 'a', limit: 2 }); + assert(result.success); expect(result.runs).toHaveLength(2); - expect(result.runs![0]!.timestamp).toBe('2025-01-15T10:00:00.000Z'); - expect(result.runs![1]!.timestamp).toBe('2025-01-15T12:00:00.000Z'); + expect(result.runs[0]!.timestamp).toBe('2025-01-15T10:00:00.000Z'); + expect(result.runs[1]!.timestamp).toBe('2025-01-15T12:00:00.000Z'); }); it('returns empty array when no runs exist', () => { @@ -82,7 +84,7 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({}); - expect(result.success).toBe(true); + assert(result.success); expect(result.runs).toEqual([]); }); @@ -93,9 +95,8 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({}); - expect(result.success).toBe(false); - expect(result.error).toBe('disk error'); - expect(result.runs).toBeUndefined(); + assert(!result.success); + expect(result.error.message).toBe('disk error'); }); it('handles non-Error thrown values', () => { @@ -105,7 +106,7 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({}); - expect(result.success).toBe(false); - expect(result.error).toBe('42'); + assert(!result.success); + expect(result.error.message).toBe('42'); }); }); diff --git a/src/cli/operations/eval/__tests__/logs-eval.test.ts b/src/cli/operations/eval/__tests__/logs-eval.test.ts index 5411d842e..a363941fd 100644 --- a/src/cli/operations/eval/__tests__/logs-eval.test.ts +++ b/src/cli/operations/eval/__tests__/logs-eval.test.ts @@ -1,4 +1,5 @@ import { handleLogsEval } from '../logs-eval.js'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockLoadDeployedProjectConfig = vi.fn(); @@ -100,8 +101,8 @@ describe('handleLogsEval', () => { const result = await handleLogsEval({}); - expect(result.success).toBe(false); - expect(result.error).toBe('No agents defined'); + assert(!result.success); + expect(result.error.message).toBe('No agents defined'); }); it('returns error when no online eval configs exist for the agent', async () => { @@ -111,8 +112,8 @@ describe('handleLogsEval', () => { const result = await handleLogsEval({}); - expect(result.success).toBe(false); - expect(result.error).toContain('No deployed online eval configs found'); + assert(!result.success); + expect(result.error.message).toContain('No deployed online eval configs found'); }); it('returns error when online eval configs exist but none are deployed', async () => { @@ -122,8 +123,8 @@ describe('handleLogsEval', () => { const result = await handleLogsEval({}); - expect(result.success).toBe(false); - expect(result.error).toContain('No deployed online eval configs found'); + assert(!result.success); + expect(result.error.message).toContain('No deployed online eval configs found'); }); it('searches logs with time range when --since is specified', async () => { diff --git a/src/cli/operations/eval/__tests__/pause-resume.test.ts b/src/cli/operations/eval/__tests__/pause-resume.test.ts index 834525675..9a6291a3d 100644 --- a/src/cli/operations/eval/__tests__/pause-resume.test.ts +++ b/src/cli/operations/eval/__tests__/pause-resume.test.ts @@ -1,4 +1,5 @@ import { handlePauseResume } from '../pause-resume.js'; +import assert from 'node:assert'; import { afterEach, describe, expect, it, vi } from 'vitest'; const mockLoadDeployedProjectConfig = vi.fn(); @@ -46,7 +47,7 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'my-config' }, 'pause'); - expect(result.success).toBe(true); + assert(result.success); expect(result.executionStatus).toBe('DISABLED'); expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalledWith({ region: 'us-east-1', @@ -65,7 +66,7 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'my-config' }, 'resume'); - expect(result.success).toBe(true); + assert(result.success); expect(result.executionStatus).toBe('ENABLED'); expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalledWith({ region: 'us-east-1', @@ -83,8 +84,8 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'my-config' }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toContain('No deployed targets found'); + assert(!result.success); + expect(result.error.message).toContain('No deployed targets found'); }); it('returns error when config name is not found in deployed state', async () => { @@ -92,9 +93,9 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'missing-config' }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toContain('missing-config'); - expect(result.error).toContain('not found'); + assert(!result.success); + expect(result.error.message).toContain('missing-config'); + expect(result.error.message).toContain('not found'); }); it('returns error when target config is missing from aws-targets', async () => { @@ -105,9 +106,9 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'my-config' }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toContain('Target config'); - expect(result.error).toContain('not found'); + assert(!result.success); + expect(result.error.message).toContain('Target config'); + expect(result.error.message).toContain('not found'); }); it('returns error when the SDK call fails', async () => { @@ -116,8 +117,8 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'my-config' }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toBe('Service unavailable'); + assert(!result.success); + expect(result.error.message).toBe('Service unavailable'); }); describe('ARN mode', () => { @@ -131,7 +132,7 @@ describe('handlePauseResume', () => { const arn = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/my-cfg-id'; const result = await handlePauseResume({ name: '', arn }, 'pause'); - expect(result.success).toBe(true); + assert(result.success); expect(result.executionStatus).toBe('DISABLED'); expect(mockLoadDeployedProjectConfig).not.toHaveBeenCalled(); expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalledWith({ @@ -151,7 +152,7 @@ describe('handlePauseResume', () => { const arn = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/my-cfg-id'; const result = await handlePauseResume({ name: '', arn, region: 'eu-west-1' }, 'resume'); - expect(result.success).toBe(true); + assert(result.success); expect(result.executionStatus).toBe('ENABLED'); expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalledWith({ region: 'eu-west-1', @@ -163,16 +164,16 @@ describe('handlePauseResume', () => { it('returns error for invalid ARN', async () => { const result = await handlePauseResume({ name: '', arn: 'not-an-arn' }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid online eval config ARN'); + assert(!result.success); + expect(result.error.message).toContain('Invalid online eval config ARN'); }); it('returns error when config ID cannot be extracted from ARN', async () => { const arn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:some-other-resource/foo'; const result = await handlePauseResume({ name: '', arn }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toContain('Could not extract config ID'); + assert(!result.success); + expect(result.error.message).toContain('Could not extract config ID'); }); }); }); diff --git a/src/cli/operations/eval/__tests__/run-eval.test.ts b/src/cli/operations/eval/__tests__/run-eval.test.ts index 39314af69..7b6279b28 100644 --- a/src/cli/operations/eval/__tests__/run-eval.test.ts +++ b/src/cli/operations/eval/__tests__/run-eval.test.ts @@ -1,4 +1,5 @@ import { handleRunEval } from '../run-eval.js'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -150,8 +151,8 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); - expect(result.success).toBe(false); - expect(result.error).toBe('No agents defined'); + assert(!result.success); + expect(result.error.message).toBe('No agents defined'); }); it('returns error when a custom evaluator is not found in deployed state', async () => { @@ -170,9 +171,9 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['MissingEval'], days: 7 }); - expect(result.success).toBe(false); - expect(result.error).toContain('MissingEval'); - expect(result.error).toContain('not found in deployed state'); + assert(!result.success); + expect(result.error.message).toContain('MissingEval'); + expect(result.error.message).toContain('not found in deployed state'); }); it('resolves builtin evaluators without deployed state lookup', async () => { @@ -195,7 +196,8 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); // Fails because no spans, but NOT because evaluator wasn't found - expect(result.error).toContain('No session spans found'); + assert(!result.success); + expect(result.error.message).toContain('No session spans found'); }); it('resolves custom evaluator name to deployed evaluator ID', async () => { @@ -278,9 +280,9 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); - expect(result.success).toBe(false); - expect(result.error).toContain('No session spans found'); - expect(result.error).toContain('my-agent'); + assert(!result.success); + expect(result.error.message).toContain('No session spans found'); + expect(result.error.message).toContain('my-agent'); }); // ─── Successful evaluation ──────────────────────────────────────────────── @@ -324,12 +326,12 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); - expect(result.success).toBe(true); + assert(result.success); expect(result.run).toBeDefined(); - expect(result.run!.sessionCount).toBe(2); - expect(result.run!.results).toHaveLength(1); + expect(result.run.sessionCount).toBe(2); + expect(result.run.results).toHaveLength(1); - const evalResult = result.run!.results[0]!; + const evalResult = result.run.results[0]!; expect(evalResult.aggregateScore).toBe(3.0); // (4 + 2) / 2 expect(evalResult.sessionScores).toHaveLength(2); expect(evalResult.tokenUsage).toEqual({ inputTokens: 180, outputTokens: 90, totalTokens: 270 }); @@ -361,8 +363,8 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); - expect(result.success).toBe(true); - const evalResult = result.run!.results[0]!; + assert(result.success); + const evalResult = result.run.results[0]!; // Only the non-errored session (value 5.0) should be in the aggregate expect(evalResult.aggregateScore).toBe(5.0); expect(evalResult.sessionScores).toHaveLength(2); @@ -391,7 +393,7 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); - expect(result.success).toBe(true); + assert(result.success); expect(mockSaveEvalRun).toHaveBeenCalled(); expect(mockWriteFileSync).not.toHaveBeenCalled(); expect(result.filePath).toBe('/tmp/eval-results/eval_2025-01-15_10-00-00.json'); @@ -422,7 +424,7 @@ describe('handleRunEval', () => { output: '/tmp/my-output.json', }); - expect(result.success).toBe(true); + assert(result.success); expect(mockWriteFileSync).toHaveBeenCalledWith('/tmp/my-output.json', expect.any(String)); expect(mockSaveEvalRun).not.toHaveBeenCalled(); expect(result.filePath).toBe('/tmp/my-output.json'); @@ -461,12 +463,12 @@ describe('handleRunEval', () => { days: 7, }); - expect(result.success).toBe(true); - expect(result.run!.results).toHaveLength(2); - expect(result.run!.results[0]!.evaluator).toBe('Builtin.GoalSuccessRate'); - expect(result.run!.results[0]!.aggregateScore).toBe(0.9); - expect(result.run!.results[1]!.evaluator).toBe('CustomEval'); - expect(result.run!.results[1]!.aggregateScore).toBe(4.5); + assert(result.success); + expect(result.run.results).toHaveLength(2); + expect(result.run.results[0]!.evaluator).toBe('Builtin.GoalSuccessRate'); + expect(result.run.results[0]!.aggregateScore).toBe(0.9); + expect(result.run.results[1]!.evaluator).toBe('CustomEval'); + expect(result.run.results[1]!.aggregateScore).toBe(4.5); }); // ─── ARN mode ───────────────────────────────────────────────────────────── @@ -484,8 +486,8 @@ describe('handleRunEval', () => { days: 3, }); - expect(result.success).toBe(true); - expect(result.run!.agent).toBe('rt-arn-test'); + assert(result.success); + expect(result.run.agent).toBe('rt-arn-test'); expect(mockLoadDeployedProjectConfig).not.toHaveBeenCalled(); expect(mockResolveAgent).not.toHaveBeenCalled(); }); @@ -532,8 +534,8 @@ describe('handleRunEval', () => { days: 7, }); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid agent runtime ARN'); + assert(!result.success); + expect(result.error.message).toContain('Invalid agent runtime ARN'); }); it('rejects custom evaluator names in ARN mode', async () => { @@ -543,8 +545,8 @@ describe('handleRunEval', () => { days: 7, }); - expect(result.success).toBe(false); - expect(result.error).toContain('cannot be resolved in ARN mode'); + assert(!result.success); + expect(result.error.message).toContain('cannot be resolved in ARN mode'); }); it('saves to cwd in ARN mode when no --output is specified', async () => { @@ -559,7 +561,7 @@ describe('handleRunEval', () => { days: 7, }); - expect(result.success).toBe(true); + assert(result.success); // Should write to cwd, not call saveEvalRun (which requires a project) expect(mockSaveEvalRun).not.toHaveBeenCalled(); expect(mockWriteFileSync).toHaveBeenCalledWith( @@ -582,7 +584,7 @@ describe('handleRunEval', () => { output: '/tmp/custom-eval.json', }); - expect(result.success).toBe(true); + assert(result.success); expect(mockWriteFileSync).toHaveBeenCalledWith('/tmp/custom-eval.json', expect.any(String)); expect(result.filePath).toBe('/tmp/custom-eval.json'); }); @@ -594,8 +596,8 @@ describe('handleRunEval', () => { days: 7, }); - expect(result.success).toBe(false); - expect(result.error).toContain('No evaluators specified'); + assert(!result.success); + expect(result.error.message).toContain('No evaluators specified'); }); // ─── Endpoint selection ────────────────────────────────────────────────── @@ -1199,8 +1201,8 @@ describe('handleRunEval', () => { expectedTrajectory: ['tool_1', 'tool_2'], }); - expect(result.success).toBe(true); - expect(result.run!.referenceInputs).toEqual({ + assert(result.success); + expect(result.run.referenceInputs).toEqual({ expectedTrajectory: ['tool_1', 'tool_2'], }); }); @@ -1215,7 +1217,7 @@ describe('handleRunEval', () => { assertions: ['Agent should greet user'], }); - expect(result.success).toBe(false); - expect(result.error).toContain('require exactly one session'); + assert(!result.success); + expect(result.error.message).toContain('require exactly one session'); }); }); diff --git a/src/cli/operations/eval/index.ts b/src/cli/operations/eval/index.ts index de5999569..66efbbb21 100644 --- a/src/cli/operations/eval/index.ts +++ b/src/cli/operations/eval/index.ts @@ -5,6 +5,5 @@ export type { ListEvalRunsResult } from './list-eval-runs'; export { handlePauseResume } from './pause-resume'; export type { PauseResumeResult } from './pause-resume'; export { handleLogsEval } from './logs-eval'; -export type { LogsEvalResult } from './logs-eval'; export type { EvalRunResult, RunEvalOptions, ListEvalRunsOptions, OnlineEvalActionOptions, SessionInfo } from './types'; export type { LogsEvalOptions } from './logs-eval'; diff --git a/src/cli/operations/eval/list-eval-runs.ts b/src/cli/operations/eval/list-eval-runs.ts index 66b0ed528..9b79a9ebc 100644 --- a/src/cli/operations/eval/list-eval-runs.ts +++ b/src/cli/operations/eval/list-eval-runs.ts @@ -1,12 +1,9 @@ -import { getErrorMessage } from '../../errors'; +import { toError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import { listEvalRuns } from './storage'; import type { EvalRunResult, ListEvalRunsOptions } from './types'; -export interface ListEvalRunsResult { - success: boolean; - error?: string; - runs?: EvalRunResult[]; -} +export type ListEvalRunsResult = Result<{ runs: EvalRunResult[] }>; export function handleListEvalRuns(options: ListEvalRunsOptions): ListEvalRunsResult { try { @@ -22,6 +19,6 @@ export function handleListEvalRuns(options: ListEvalRunsOptions): ListEvalRunsRe return { success: true, runs }; } catch (err) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: toError(err) }; } } diff --git a/src/cli/operations/eval/logs-eval.ts b/src/cli/operations/eval/logs-eval.ts index 430103a44..70df24eb4 100644 --- a/src/cli/operations/eval/logs-eval.ts +++ b/src/cli/operations/eval/logs-eval.ts @@ -1,3 +1,5 @@ +import { ResourceNotFoundError, ValidationError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import { parseTimeString } from '../../../lib/utils'; import { getOnlineEvaluationConfig } from '../../aws/agentcore-control'; import { searchLogs, streamLogs } from '../../aws/cloudwatch'; @@ -13,11 +15,6 @@ export interface LogsEvalOptions { follow?: boolean; } -export interface LogsEvalResult { - success: boolean; - error?: string; -} - function formatLogLine(event: { timestamp: number; message: string }, json: boolean): string { if (json) { return JSON.stringify({ timestamp: new Date(event.timestamp).toISOString(), message: event.message }); @@ -70,12 +67,12 @@ async function resolveEvalLogGroups( return results; } -export async function handleLogsEval(options: LogsEvalOptions): Promise { +export async function handleLogsEval(options: LogsEvalOptions): Promise { const context = await loadDeployedProjectConfig(); const agentResult = resolveAgent(context, { runtime: options.agent }); if (!agentResult.success) { - return { success: false, error: agentResult.error }; + return { success: false, error: new ResourceNotFoundError(agentResult.error) }; } const { agent } = agentResult; @@ -85,7 +82,9 @@ export async function handleLogsEval(options: LogsEvalOptions): Promise; async function resolveOnlineEvalConfig( configName: string @@ -88,7 +85,7 @@ export async function handlePauseResume( ): Promise { const resolution = await resolveConfig(options); if (!resolution.success) { - return resolution; + return { success: false, error: new ResourceNotFoundError(resolution.error) }; } const executionStatus: OnlineEvalExecutionStatus = action === 'pause' ? 'DISABLED' : 'ENABLED'; @@ -106,6 +103,6 @@ export async function handlePauseResume( executionStatus: result.executionStatus, }; } catch (err) { - return { success: false, error: (err as Error).message }; + return { success: false, error: toError(err) }; } } diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index 0962f4e0a..436cace71 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -6,7 +6,8 @@ * 4. Poll GetBatchEvaluation until terminal status * 5. Return results */ -import { ConfigIO } from '../../../lib'; +import { ConfigIO, ResourceNotFoundError, ValidationError, toError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import type { DeployedState } from '../../../schema'; import { generateClientToken, getBatchEvaluation, startBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; import type { @@ -54,9 +55,7 @@ export interface BatchEvaluationResult { error?: string; } -export interface RunBatchEvaluationCommandResult { - success: boolean; - error?: string; +export type RunBatchEvaluationCommandResult = Result & { batchEvaluationId?: string; name?: string; status?: string; @@ -65,7 +64,7 @@ export interface RunBatchEvaluationCommandResult { startedAt?: string; completedAt?: string; logFilePath?: string; -} +}; // ============================================================================ // Constants @@ -116,7 +115,7 @@ export async function runBatchEvaluationCommand( logger?.log(error, 'error'); logger?.endStep('error', error); logger?.finalize(false); - return { success: false, error, results: [], logFilePath: logger?.logFilePath }; + return { success: false, error: new ResourceNotFoundError(error), results: [], logFilePath: logger?.logFilePath }; } const runtimeId = agentState.runtimeId; @@ -152,7 +151,9 @@ export async function runBatchEvaluationCommand( if (!/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/.test(options.name)) { return { success: false, - error: `Batch evaluation name must start with a letter and contain only letters, digits, and underscores (max 48 chars). Got: "${options.name}"`, + error: new ValidationError( + `Batch evaluation name must start with a letter and contain only letters, digits, and underscores (max 48 chars). Got: "${options.name}"` + ), results: [], logFilePath: logger?.logFilePath, }; @@ -242,7 +243,7 @@ export async function runBatchEvaluationCommand( logger?.finalize(false); return { success: false, - error, + error: new Error(error), batchEvaluationId: startResult.batchEvaluationId, name: evalName, status: current.status, @@ -287,7 +288,7 @@ export async function runBatchEvaluationCommand( const error = err instanceof Error ? err.message : String(err); logger?.log(error, 'error'); logger?.finalize(false); - return { success: false, error, results: [], logFilePath: logger?.logFilePath }; + return { success: false, error: toError(err), results: [], logFilePath: logger?.logFilePath }; } } diff --git a/src/cli/operations/eval/run-eval.ts b/src/cli/operations/eval/run-eval.ts index 90cd519c7..8ba5cb307 100644 --- a/src/cli/operations/eval/run-eval.ts +++ b/src/cli/operations/eval/run-eval.ts @@ -1,3 +1,5 @@ +import { ResourceNotFoundError, ValidationError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import { getCredentialProvider } from '../../aws'; import { evaluate } from '../../aws/agentcore'; import type { EvaluationReferenceInput } from '../../aws/agentcore'; @@ -551,12 +553,7 @@ async function fetchSessionSpans(opts: FetchSpansOptions): Promise; export async function handleRunEval(options: RunEvalOptions): Promise { let resolution: ResolveResult; @@ -569,7 +566,7 @@ export async function handleRunEval(options: RunEvalOptions): Promise ({ configExists = mockConfigExists; }, requireConfigRoot: () => '/project/agentcore', + findConfigRoot: () => '/project/agentcore', + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, + APP_DIR: 'app', + MCP_APP_SUBDIR: 'mcp', })); const computeDefaultGatewayEnvVarName = (name: string) => GatewayPrimitive.computeDefaultGatewayEnvVarName(name); @@ -200,7 +211,10 @@ describe('GatewayPrimitive.add (createGateway)', () => { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('Gateway "dup-gw" already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('Gateway "dup-gw" already exists') }), + }) ); }); diff --git a/src/cli/operations/memory/__tests__/create-memory.test.ts b/src/cli/operations/memory/__tests__/create-memory.test.ts index a0b8077c4..ad9cef2c8 100644 --- a/src/cli/operations/memory/__tests__/create-memory.test.ts +++ b/src/cli/operations/memory/__tests__/create-memory.test.ts @@ -15,6 +15,15 @@ vi.mock('../../../../lib/index.js', () => ({ readProjectSpec = mockReadProjectSpec; writeProjectSpec = mockWriteProjectSpec; }, + findConfigRoot: () => '/fake/root', + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, })); const makeProject = (memoryNames: string[]) => ({ @@ -86,7 +95,7 @@ describe('add', () => { expiry: 30, }); - expect(result).toEqual(expect.objectContaining({ success: false, error: expect.any(String) })); + expect(result).toEqual(expect.objectContaining({ success: false, error: expect.any(Error) })); expect(mockWriteProjectSpec).not.toHaveBeenCalled(); }); @@ -96,7 +105,10 @@ describe('add', () => { const result = await primitive.add({ name: 'Existing', strategies: '', expiry: 30 }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('Memory "Existing" already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('Memory "Existing" already exists') }), + }) ); }); }); diff --git a/src/cli/operations/memory/list-memory-records.ts b/src/cli/operations/memory/list-memory-records.ts index 8bbf34628..efa5f1bf5 100644 --- a/src/cli/operations/memory/list-memory-records.ts +++ b/src/cli/operations/memory/list-memory-records.ts @@ -1,3 +1,5 @@ +import { ResourceNotFoundError, toError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import { getCredentialProvider } from '../../aws'; import { BedrockAgentCoreClient, ListMemoryRecordsCommand } from '@aws-sdk/client-bedrock-agentcore'; @@ -20,12 +22,7 @@ export interface ListMemoryRecordsOptions { nextToken?: string; } -export interface ListMemoryRecordsResult { - success: boolean; - records?: MemoryRecordEntry[]; - nextToken?: string; - error?: string; -} +export type ListMemoryRecordsResult = Result<{ records: MemoryRecordEntry[]; nextToken?: string }>; /** * Lists memory records for a deployed memory resource via the AWS SDK. @@ -63,8 +60,11 @@ export async function listMemoryRecords(options: ListMemoryRecordsOptions): Prom } catch (error: unknown) { const err = error as Error; if (err.name === 'ResourceNotFoundException') { - return { success: false, error: `Memory '${memoryId}' not found. It may not have been deployed yet.` }; + return { + success: false, + error: new ResourceNotFoundError(`Memory '${memoryId}' not found. It may not have been deployed yet.`), + }; } - return { success: false, error: err.message ?? String(error) }; + return { success: false, error: toError(error) }; } } diff --git a/src/cli/operations/memory/retrieve-memory-records.ts b/src/cli/operations/memory/retrieve-memory-records.ts index e8d2a65ed..a6b9905e2 100644 --- a/src/cli/operations/memory/retrieve-memory-records.ts +++ b/src/cli/operations/memory/retrieve-memory-records.ts @@ -1,3 +1,5 @@ +import { ResourceNotFoundError, toError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import { getCredentialProvider } from '../../aws'; import type { MemoryRecordEntry } from './list-memory-records'; import { BedrockAgentCoreClient, RetrieveMemoryRecordsCommand } from '@aws-sdk/client-bedrock-agentcore'; @@ -13,12 +15,7 @@ export interface RetrieveMemoryRecordsOptions { nextToken?: string; } -export interface RetrieveMemoryRecordsResult { - success: boolean; - records?: MemoryRecordEntry[]; - nextToken?: string; - error?: string; -} +export type RetrieveMemoryRecordsResult = Result<{ records: MemoryRecordEntry[]; nextToken?: string }>; /** * Searches memory records using semantic retrieval via the AWS SDK. @@ -62,8 +59,11 @@ export async function retrieveMemoryRecords( } catch (error: unknown) { const err = error as Error; if (err.name === 'ResourceNotFoundException') { - return { success: false, error: `Memory '${memoryId}' not found. It may not have been deployed yet.` }; + return { + success: false, + error: new ResourceNotFoundError(`Memory '${memoryId}' not found. It may not have been deployed yet.`), + }; } - return { success: false, error: err.message ?? String(error) }; + return { success: false, error: toError(error) }; } } diff --git a/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts b/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts index 5e0fb668a..981f00a75 100644 --- a/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts +++ b/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts @@ -1,6 +1,7 @@ import type { ConfigIO } from '../../../../lib'; import type { RecommendationResult } from '../../../aws/agentcore-recommendation'; import { applyRecommendationToBundle } from '../apply-to-bundle'; +import assert from 'node:assert'; import { describe, expect, it, vi } from 'vitest'; const { RUNTIME_ARN, BUNDLE_ARN, NEW_VERSION_ID } = vi.hoisted(() => ({ @@ -98,7 +99,7 @@ describe('applyRecommendationToBundle', () => { configIO ); - expect(applyResult.success).toBe(true); + assert(applyResult.success); expect(applyResult.newVersionId).toBe(NEW_VERSION_ID); // Verify spec was written with server components @@ -132,7 +133,7 @@ describe('applyRecommendationToBundle', () => { configIO ); - expect(applyResult.success).toBe(true); + assert(applyResult.success); expect(applyResult.newVersionId).toBe(NEW_VERSION_ID); }); @@ -152,7 +153,7 @@ describe('applyRecommendationToBundle', () => { configIO ); - expect(applyResult.success).toBe(true); + assert(applyResult.success); expect(applyResult.newVersionId).toBe(NEW_VERSION_ID); }); @@ -171,8 +172,8 @@ describe('applyRecommendationToBundle', () => { configIO ); - expect(applyResult.success).toBe(false); - expect(applyResult.error).toContain('does not contain a new config bundle version'); + assert(!applyResult.success); + expect(applyResult.error.message).toContain('does not contain a new config bundle version'); expect(writeSpecSpy).not.toHaveBeenCalled(); }); @@ -192,8 +193,8 @@ describe('applyRecommendationToBundle', () => { configIO ); - expect(applyResult.success).toBe(false); - expect(applyResult.error).toContain('NonExistent'); + assert(!applyResult.success); + expect(applyResult.error.message).toContain('NonExistent'); expect(writeSpecSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts b/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts index f6a60b6e8..55c5b6eae 100644 --- a/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts +++ b/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts @@ -17,7 +17,9 @@ function makeTmpDir(): string { return dir; } -function makeResult(overrides: Partial = {}): RunRecommendationCommandResult { +function makeResult( + overrides: Partial> = {} +): RunRecommendationCommandResult { return { success: true, recommendationId: 'rec-123', diff --git a/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts index b26a59b32..765ee6692 100644 --- a/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts +++ b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts @@ -1,4 +1,5 @@ import { runRecommendationCommand } from '../run-recommendation'; +import assert from 'node:assert'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock dependencies — paths are relative to the file under test (run-recommendation.ts) @@ -29,6 +30,25 @@ vi.mock('../../../../lib', () => ({ readDeployedState = mockReadDeployedState; resolveAWSDeploymentTargets = vi.fn().mockResolvedValue([{ region: 'us-east-1' }]); }, + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, + ValidationError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ValidationError'; + } + }, + TimeoutError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'TimeoutError'; + } + }, })); vi.mock('../../../aws/region', () => ({ @@ -71,9 +91,9 @@ describe('runRecommendationCommand', () => { traceSource: 'cloudwatch', }); - expect(result.success).toBe(false); - expect(result.error).toContain('NonExistentAgent'); - expect(result.error).toContain('not deployed'); + assert(!result.success); + expect(result.error.message).toContain('NonExistentAgent'); + expect(result.error.message).toContain('not deployed'); }); it('returns error when evaluator cannot be resolved', async () => { @@ -86,9 +106,9 @@ describe('runRecommendationCommand', () => { traceSource: 'cloudwatch', }); - expect(result.success).toBe(false); - expect(result.error).toContain('UnknownEvaluator'); - expect(result.error).toContain('not found'); + assert(!result.success); + expect(result.error.message).toContain('UnknownEvaluator'); + expect(result.error.message).toContain('not found'); }); it('returns result on COMPLETED status', async () => { @@ -123,7 +143,7 @@ describe('runRecommendationCommand', () => { pollIntervalMs: 0, }); - expect(result.success).toBe(true); + assert(result.success); expect(result.recommendationId).toBe('rec-001'); expect(result.status).toBe('COMPLETED'); expect(result.result?.systemPromptRecommendationResult?.recommendedSystemPrompt).toBe('Optimized prompt'); @@ -153,8 +173,8 @@ describe('runRecommendationCommand', () => { pollIntervalMs: 0, }); - expect(result.success).toBe(false); - expect(result.error).toContain('FAILED'); + assert(!result.success); + expect(result.error.message).toContain('FAILED'); expect(result.recommendationId).toBe('rec-002'); }); @@ -287,8 +307,8 @@ describe('runRecommendationCommand', () => { traceSource: 'cloudwatch', }); - expect(result.success).toBe(false); - expect(result.error).toContain('API timeout'); + assert(!result.success); + expect(result.error.message).toContain('API timeout'); }); it('retries transient poll failures and succeeds', async () => { @@ -345,10 +365,10 @@ describe('runRecommendationCommand', () => { pollIntervalMs: 0, }); - expect(result.success).toBe(false); - expect(result.error).toContain('consecutive errors'); - expect(result.error).toContain('fetch failed'); - expect(result.error).toContain('rec-retry-fail'); + assert(!result.success); + expect(result.error.message).toContain('consecutive errors'); + expect(result.error.message).toContain('fetch failed'); + expect(result.error.message).toContain('rec-retry-fail'); expect(mockGetRecommendation).toHaveBeenCalledTimes(3); }); @@ -377,9 +397,9 @@ describe('runRecommendationCommand', () => { maxPollDurationMs: 0, // Immediately timeout }); - expect(result.success).toBe(false); - expect(result.error).toContain('Polling timed out'); - expect(result.error).toContain('rec-timeout'); + assert(!result.success); + expect(result.error.message).toContain('Polling timed out'); + expect(result.error.message).toContain('rec-timeout'); }); it('reads system prompt from file when inputSource is file', async () => { @@ -538,8 +558,8 @@ describe('runRecommendationCommand', () => { pollIntervalMs: 0, }); - expect(result.success).toBe(false); - expect(result.error).toContain('No spans found'); + assert(!result.success); + expect(result.error.message).toContain('No spans found'); }); it('derives service name from runtimeId by stripping hash suffix', async () => { @@ -664,10 +684,10 @@ describe('runRecommendationCommand', () => { pollIntervalMs: 0, }); - expect(result.success).toBe(false); - expect(result.error).toContain('Insufficient trace data'); - expect(result.error).toContain('INSUFFICIENT_DATA'); - expect(result.error).toContain('Not enough traces'); + assert(!result.success); + expect(result.error.message).toContain('Insufficient trace data'); + expect(result.error.message).toContain('INSUFFICIENT_DATA'); + expect(result.error.message).toContain('Not enough traces'); // Request IDs are logged to file only, not included in the error message }); diff --git a/src/cli/operations/recommendation/apply-to-bundle.ts b/src/cli/operations/recommendation/apply-to-bundle.ts index bf9060d10..26d0f00c6 100644 --- a/src/cli/operations/recommendation/apply-to-bundle.ts +++ b/src/cli/operations/recommendation/apply-to-bundle.ts @@ -9,7 +9,8 @@ * This module fetches that new version via GetConfigurationBundleVersion and * updates the local agentcore.json components to match the server state. */ -import { ConfigIO } from '../../../lib'; +import { ConfigIO, ResourceNotFoundError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import { getConfigurationBundleVersion } from '../../aws/agentcore-config-bundles'; import type { RecommendationResult } from '../../aws/agentcore-recommendation'; @@ -24,12 +25,7 @@ export interface ApplyRecommendationOptions { region: string; } -export interface ApplyRecommendationResult { - success: boolean; - error?: string; - /** New version ID that was synced from the server */ - newVersionId?: string; -} +export type ApplyRecommendationResult = Result<{ newVersionId?: string }>; /** * Extract the bundleId from a bundle ARN. @@ -58,8 +54,9 @@ export async function applyRecommendationToBundle( if (!resultBundle) { return { success: false, - error: - 'Recommendation result does not contain a new config bundle version. The server may not have applied the recommendation to the bundle.', + error: new Error( + 'Recommendation result does not contain a new config bundle version. The server may not have applied the recommendation to the bundle.' + ), }; } @@ -67,7 +64,7 @@ export async function applyRecommendationToBundle( if (!bundleId) { return { success: false, - error: `Could not extract bundle ID from ARN: ${resultBundle.bundleArn}`, + error: new Error(`Could not extract bundle ID from ARN: ${resultBundle.bundleArn}`), }; } @@ -107,7 +104,7 @@ export async function applyRecommendationToBundle( if (!bundle) { return { success: false, - error: `Config bundle "${identifier}" not found in agentcore.json.`, + error: new ResourceNotFoundError(`Config bundle "${identifier}" not found in agentcore.json.`), }; } diff --git a/src/cli/operations/recommendation/recommendation-storage.ts b/src/cli/operations/recommendation/recommendation-storage.ts index ad8aa7160..2049535e3 100644 --- a/src/cli/operations/recommendation/recommendation-storage.ts +++ b/src/cli/operations/recommendation/recommendation-storage.ts @@ -43,9 +43,9 @@ export function saveRecommendationRun( agent, evaluators, status: result.status ?? 'unknown', - startedAt: result.startedAt, - completedAt: result.completedAt, - result: result.result, + startedAt: result.success ? result.startedAt : undefined, + completedAt: result.success ? result.completedAt : undefined, + result: result.success ? result.result : undefined, }; writeFileSync(filePath, JSON.stringify(record, null, 2)); diff --git a/src/cli/operations/recommendation/run-recommendation.ts b/src/cli/operations/recommendation/run-recommendation.ts index 0423cfe32..d277c01cb 100644 --- a/src/cli/operations/recommendation/run-recommendation.ts +++ b/src/cli/operations/recommendation/run-recommendation.ts @@ -6,7 +6,7 @@ * 4. Poll GetRecommendation until terminal status * 5. Return result with optimized artifact */ -import { ConfigIO } from '../../../lib'; +import { ConfigIO, ResourceNotFoundError, TimeoutError, ValidationError, toError } from '../../../lib'; import type { DeployedState } from '../../../schema'; import type { RecommendationConfig, @@ -60,7 +60,7 @@ export async function runRecommendationCommand( logger?.finalize(false); return { success: false, - error: `Agent "${options.agent}" not deployed. Run \`agentcore deploy\` first.`, + error: new Error(`Agent "${options.agent}" not deployed. Run \`agentcore deploy\` first.`), logFilePath: logger?.logFilePath, }; } @@ -73,7 +73,9 @@ export async function runRecommendationCommand( if (!evaluatorId) { return { success: false, - error: `Evaluator "${evaluator}" not found in deployed state. Use a Builtin.* name, a full ARN, or deploy a custom evaluator first.`, + error: new Error( + `Evaluator "${evaluator}" not found in deployed state. Use a Builtin.* name, a full ARN, or deploy a custom evaluator first.` + ), logFilePath: logger?.logFilePath, }; } @@ -82,7 +84,7 @@ export async function runRecommendationCommand( if (options.type === 'SYSTEM_PROMPT_RECOMMENDATION' && evaluatorIds.length !== 1) { return { success: false, - error: 'System prompt recommendations require exactly one evaluator.', + error: new ValidationError('System prompt recommendations require exactly one evaluator.'), logFilePath: logger?.logFilePath, }; } @@ -105,7 +107,9 @@ export async function runRecommendationCommand( ) { return { success: false, - error: 'System prompt content is required. Provide via --inline, --prompt-file, or --bundle-name.', + error: new ValidationError( + 'System prompt content is required. Provide via --inline, --prompt-file, or --bundle-name.' + ), logFilePath: logger?.logFilePath, }; } @@ -132,7 +136,9 @@ export async function runRecommendationCommand( if (!bundleArn) { return { success: false, - error: `Config bundle "${options.bundleName}" not found in deployed state. Run \`agentcore deploy\` first.`, + error: new ResourceNotFoundError( + `Config bundle "${options.bundleName}" not found in deployed state. Run \`agentcore deploy\` first.` + ), logFilePath: logger?.logFilePath, }; } @@ -230,7 +236,9 @@ export async function runRecommendationCommand( logger?.finalize(false); return { success: false, - error: `Polling timed out after ${Math.round(maxDurationMs / 60000)} minutes. The recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}`, + error: new TimeoutError( + `Polling timed out after ${Math.round(maxDurationMs / 60000)} minutes. The recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}` + ), recommendationId: startResult.recommendationId, status: currentStatus, logFilePath: logger?.logFilePath, @@ -255,7 +263,9 @@ export async function runRecommendationCommand( logger?.finalize(false); return { success: false, - error: `Polling failed after ${MAX_POLL_RETRIES} consecutive errors: ${pollErrMsg}\nThe recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}`, + error: new TimeoutError( + `Polling failed after ${MAX_POLL_RETRIES} consecutive errors: ${pollErrMsg}\nThe recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}` + ), recommendationId: startResult.recommendationId, status: currentStatus, logFilePath: logger?.logFilePath, @@ -303,9 +313,11 @@ export async function runRecommendationCommand( return { success: false, - error: failureDetails - ? `Recommendation failed: ${failureDetails}` - : `Recommendation finished with status: ${currentStatus}`, + error: new Error( + failureDetails + ? `Recommendation failed: ${failureDetails}` + : `Recommendation finished with status: ${currentStatus}` + ), recommendationId: startResult.recommendationId, status: currentStatus, logFilePath: logger?.logFilePath, @@ -319,7 +331,7 @@ export async function runRecommendationCommand( logger?.finalize(false); return { success: false, - error: `Recommendation ended with unexpected status: ${currentStatus}`, + error: new Error(`Recommendation ended with unexpected status: ${currentStatus}`), recommendationId: startResult.recommendationId, status: currentStatus, logFilePath: logger?.logFilePath, @@ -331,7 +343,7 @@ export async function runRecommendationCommand( logger?.finalize(false); return { success: false, - error: errorMsg, + error: toError(err), logFilePath: logger?.logFilePath, }; } diff --git a/src/cli/operations/recommendation/types.ts b/src/cli/operations/recommendation/types.ts index 426ba84a8..487681a2e 100644 --- a/src/cli/operations/recommendation/types.ts +++ b/src/cli/operations/recommendation/types.ts @@ -1,6 +1,7 @@ /** * Shared types for the recommendation feature. */ +import type { Result } from '../../../lib/result'; import type { RecommendationResult, RecommendationType } from '../../aws/agentcore-recommendation'; export type { RecommendationType } from '../../aws/agentcore-recommendation'; @@ -56,17 +57,9 @@ export interface RunRecommendationCommandOptions { onStarted?: (info: { recommendationId: string; region: string }) => void; } -export interface RunRecommendationCommandResult { - success: boolean; - error?: string; - recommendationId?: string; - status?: string; - /** The recommendation result from the API (populated on COMPLETED) */ +export type RunRecommendationCommandResult = Result<{ result?: RecommendationResult; - /** Resolved AWS region used for the recommendation */ region?: string; startedAt?: string; completedAt?: string; - /** Path to the execution log file */ - logFilePath?: string; -} +}> & { recommendationId?: string; status?: string; logFilePath?: string }; diff --git a/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts b/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts index fb2b9fedc..ece57dcb9 100644 --- a/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts +++ b/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts @@ -1,3 +1,4 @@ +import { ResourceNotFoundError } from '../../../../lib'; import { AgentPrimitive } from '../../../primitives/AgentPrimitive.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -15,6 +16,29 @@ vi.mock('../../../../lib/index.js', () => ({ readProjectSpec = mockReadProjectSpec; writeProjectSpec = mockWriteProjectSpec; }, + findConfigRoot: () => '/fake/root', + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, + ConflictError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ConflictError'; + } + }, + NoProjectError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'NoProjectError'; + } + }, + APP_DIR: 'app', + setEnvVar: vi.fn().mockResolvedValue(undefined), })); const makeProject = (agentNames: string[]) => ({ @@ -87,7 +111,7 @@ describe('remove', () => { const result = await primitive.remove('Missing'); - expect(result).toEqual({ success: false, error: 'Agent "Missing" not found.' }); + expect(result).toEqual({ success: false, error: new ResourceNotFoundError('Agent "Missing" not found.') }); }); it('returns error on exception', async () => { @@ -95,6 +119,6 @@ describe('remove', () => { const result = await primitive.remove('Agent1'); - expect(result).toEqual({ success: false, error: 'read fail' }); + expect(result).toEqual({ success: false, error: new Error('read fail') }); }); }); diff --git a/src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts b/src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts index 54293df5a..d44a4bb3c 100644 --- a/src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts +++ b/src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts @@ -1,3 +1,4 @@ +import { ResourceNotFoundError } from '../../../../lib'; import { GatewayPrimitive } from '../../../primitives/GatewayPrimitive.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -11,6 +12,15 @@ vi.mock('../../../../lib/index.js', () => ({ writeProjectSpec = mockWriteProjectSpec; configExists = mockConfigExists; }, + findConfigRoot: () => '/fake/root', + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, })); const makeMcpSpec = (gatewayNames: string[], targetsPerGateway = 0) => ({ @@ -101,7 +111,7 @@ describe('remove', () => { const result = await primitive.remove('missing'); - expect(result).toEqual({ success: false, error: 'Gateway "missing" not found.' }); + expect(result).toEqual({ success: false, error: new ResourceNotFoundError('Gateway "missing" not found.') }); }); it('returns error on exception', async () => { @@ -109,6 +119,6 @@ describe('remove', () => { const result = await primitive.remove('gw1'); - expect(result).toEqual({ success: false, error: 'read fail' }); + expect(result).toEqual({ success: false, error: new Error('read fail') }); }); }); diff --git a/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts b/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts index 1b1bbb8e7..f34cee7f3 100644 --- a/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts +++ b/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts @@ -1,3 +1,4 @@ +import { ResourceNotFoundError } from '../../../../lib'; import { CredentialPrimitive } from '../../../primitives/CredentialPrimitive.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -16,6 +17,23 @@ vi.mock('../../../../lib/index.js', () => ({ writeProjectSpec = mockWriteProjectSpec; configExists = vi.fn().mockReturnValue(false); }, + findConfigRoot: () => '/fake/root', + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, + ConflictError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ConflictError'; + } + }, + setEnvVar: vi.fn().mockResolvedValue(undefined), + getEnvVar: vi.fn(), })); const makeProject = ( @@ -93,7 +111,7 @@ describe('remove', () => { const result = await primitive.remove('Missing'); - expect(result).toEqual({ success: false, error: 'Credential "Missing" not found.' }); + expect(result).toEqual({ success: false, error: new ResourceNotFoundError('Credential "Missing" not found.') }); }); it('returns error on exception', async () => { @@ -101,6 +119,6 @@ describe('remove', () => { const result = await primitive.remove('Cred1'); - expect(result).toEqual({ success: false, error: 'read fail' }); + expect(result).toEqual({ success: false, error: new Error('read fail') }); }); }); diff --git a/src/cli/operations/remove/__tests__/remove-memory-ops.test.ts b/src/cli/operations/remove/__tests__/remove-memory-ops.test.ts index d42bedb94..29b485c15 100644 --- a/src/cli/operations/remove/__tests__/remove-memory-ops.test.ts +++ b/src/cli/operations/remove/__tests__/remove-memory-ops.test.ts @@ -1,3 +1,4 @@ +import { ResourceNotFoundError } from '../../../../lib'; import { MemoryPrimitive } from '../../../primitives/MemoryPrimitive.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -15,6 +16,15 @@ vi.mock('../../../../lib/index.js', () => ({ readProjectSpec = mockReadProjectSpec; writeProjectSpec = mockWriteProjectSpec; }, + findConfigRoot: () => '/fake/root', + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, })); const makeProject = (memoryNames: string[]) => ({ @@ -84,7 +94,7 @@ describe('remove', () => { const result = await primitive.remove('Missing'); - expect(result).toEqual({ success: false, error: 'Memory "Missing" not found.' }); + expect(result).toEqual({ success: false, error: new ResourceNotFoundError('Memory "Missing" not found.') }); }); it('returns error on exception', async () => { @@ -92,6 +102,6 @@ describe('remove', () => { const result = await primitive.remove('Mem1'); - expect(result).toEqual({ success: false, error: 'read fail' }); + expect(result).toEqual({ success: false, error: new Error('read fail') }); }); }); diff --git a/src/cli/operations/remove/remove-gateway-target.ts b/src/cli/operations/remove/remove-gateway-target.ts index 0ceebf7eb..fa84d8b70 100644 --- a/src/cli/operations/remove/remove-gateway-target.ts +++ b/src/cli/operations/remove/remove-gateway-target.ts @@ -1,6 +1,7 @@ -import { ConfigIO } from '../../../lib'; +import { ConfigIO, ResourceNotFoundError, toError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import type { AgentCoreCliMcpDefs, AgentCoreMcpSpec } from '../../../schema'; -import type { RemovalPreview, RemovalResult, SchemaChange } from './types'; +import type { RemovalPreview, SchemaChange } from './types'; import { existsSync } from 'fs'; import { rm } from 'fs/promises'; import { join } from 'path'; @@ -155,7 +156,7 @@ function computeRemovedToolMcpDefs( /** * Remove a gateway target from the project. */ -export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise { +export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise { try { const configIO = new ConfigIO(); const project = await configIO.readProjectSpec(); @@ -172,11 +173,14 @@ export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise const gateway = mcpSpec.agentCoreGateways.find(g => g.name === tool.gatewayName); if (!gateway) { - return { success: false, error: `Gateway "${tool.gatewayName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Gateway "${tool.gatewayName}" not found.`) }; } const target = gateway.targets.find(t => t.name === tool.name); if (!target) { - return { success: false, error: `Target "${tool.name}" not found in gateway "${tool.gatewayName}".` }; + return { + success: false, + error: new ResourceNotFoundError(`Target "${tool.name}" not found in gateway "${tool.gatewayName}".`), + }; } if (target.compute?.implementation && 'path' in target.compute.implementation) { toolPath = target.compute.implementation.path; @@ -200,7 +204,6 @@ export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise return { success: true }; } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return { success: false, error: message }; + return { success: false, error: toError(err) }; } } diff --git a/src/cli/operations/remove/types.ts b/src/cli/operations/remove/types.ts index 2194a66f7..66c2af228 100644 --- a/src/cli/operations/remove/types.ts +++ b/src/cli/operations/remove/types.ts @@ -21,11 +21,6 @@ export interface RemovalPreview { schemaChanges: SchemaChange[]; } -/** - * Result of a removal operation. - */ -export type RemovalResult = { success: true } | { success: false; error: string }; - /** * Snapshot of all schemas before removal (for diff computation). */ diff --git a/src/cli/operations/traces/__tests__/get-trace.test.ts b/src/cli/operations/traces/__tests__/get-trace.test.ts index 8f833cc0a..3498f6ebb 100644 --- a/src/cli/operations/traces/__tests__/get-trace.test.ts +++ b/src/cli/operations/traces/__tests__/get-trace.test.ts @@ -1,5 +1,6 @@ import { fetchTraceRecords, getTrace } from '../get-trace'; import type { FetchTraceRecordsOptions } from '../types'; +import assert from 'node:assert'; import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -57,14 +58,14 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.records).toHaveLength(2); - expect(result.records![0]).toEqual({ + 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({ + expect(result.records[1]).toEqual({ '@timestamp': '2024-01-01T00:00:01Z', '@message': { traceId: 'abc123', spanId: 'span2' }, }); @@ -76,8 +77,8 @@ describe('fetchTraceRecords', () => { traceId: 'invalid!@#$', }); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid trace ID format'); + assert(!result.success); + expect(result.error.message).toContain('Invalid trace ID format'); expect(mockSend).not.toHaveBeenCalled(); }); @@ -89,8 +90,8 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(false); - expect(result.error).toContain('No trace data found'); + assert(!result.success); + expect(result.error.message).toContain('No trace data found'); }); it('returns error when query fails to start', async () => { @@ -98,8 +99,8 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(false); - expect(result.error).toContain('Failed to start CloudWatch Logs Insights query'); + assert(!result.success); + expect(result.error.message).toContain('Failed to start CloudWatch Logs Insights query'); }); it('returns error when query status is Failed', async () => { @@ -107,8 +108,8 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(false); - expect(result.error).toContain('failed'); + assert(!result.success); + expect(result.error.message).toContain('failed'); }); it('preserves @ptr when present in CloudWatch response', async () => { @@ -125,9 +126,9 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.records).toHaveLength(1); - expect(result.records![0]!['@ptr']).toBe('cw-ptr-123'); + expect(result.records[0]!['@ptr']).toBe('cw-ptr-123'); }); it('omits @ptr when not present in CloudWatch response', async () => { @@ -143,8 +144,8 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(true); - expect(result.records![0]).not.toHaveProperty('@ptr'); + assert(result.success); + expect(result.records[0]).not.toHaveProperty('@ptr'); }); it('handles non-JSON @message gracefully', async () => { @@ -160,9 +161,9 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.records).toHaveLength(1); - expect(result.records![0]!['@message']).toBe('plain text message'); + expect(result.records[0]!['@message']).toBe('plain text message'); }); it('handles ResourceNotFoundException', async () => { @@ -172,9 +173,9 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(false); - expect(result.error).toContain('Log group'); - expect(result.error).toContain('not found'); + assert(!result.success); + expect(result.error.message).toContain('Log group'); + expect(result.error.message).toContain('not found'); }); }); @@ -212,7 +213,7 @@ describe('getTrace', () => { endTime: 2000000, }); - expect(result.success).toBe(true); + assert(result.success); expect(result.filePath).toContain('test-trace.json'); const content = JSON.parse(readFileSync(outputPath, 'utf-8')); expect(content).toHaveLength(1); @@ -231,8 +232,8 @@ describe('getTrace', () => { endTime: 2000000, }); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid trace ID format'); + assert(!result.success); + expect(result.error.message).toContain('Invalid trace ID format'); expect(existsSync(outputPath)).toBe(false); }); }); diff --git a/src/cli/operations/traces/__tests__/list-traces.test.ts b/src/cli/operations/traces/__tests__/list-traces.test.ts index 0bbe884de..ab6c8b9b9 100644 --- a/src/cli/operations/traces/__tests__/list-traces.test.ts +++ b/src/cli/operations/traces/__tests__/list-traces.test.ts @@ -1,5 +1,7 @@ +import { ResourceNotFoundError } from '../../../../lib'; import { listTraces } from '../list-traces'; import type { ListTracesOptions } from '../types'; +import assert from 'node:assert'; import { afterEach, describe, expect, it, vi } from 'vitest'; const { mockRunInsightsQuery } = vi.hoisted(() => ({ @@ -38,15 +40,15 @@ describe('listTraces', () => { const result = await listTraces(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.traces).toHaveLength(2); - expect(result.traces![0]).toEqual({ + expect(result.traces[0]).toEqual({ traceId: 'trace-1', timestamp: '2024-01-01T00:05:00Z', sessionId: 'sess-1', spanCount: '12', }); - expect(result.traces![1]).toEqual({ + expect(result.traces[1]).toEqual({ traceId: 'trace-2', timestamp: '2024-01-01T00:03:00Z', sessionId: undefined, @@ -66,9 +68,9 @@ describe('listTraces', () => { const result = await listTraces(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.traces).toHaveLength(1); - expect(result.traces![0]!.traceId).toBe('trace-1'); + expect(result.traces[0]!.traceId).toBe('trace-1'); }); it('falls back to firstSeen when lastSeen is missing', async () => { @@ -79,8 +81,8 @@ describe('listTraces', () => { const result = await listTraces(baseOptions); - expect(result.success).toBe(true); - expect(result.traces![0]!.timestamp).toBe('2024-01-01T00:00:00Z'); + assert(result.success); + expect(result.traces[0]!.timestamp).toBe('2024-01-01T00:00:00Z'); }); it('returns empty traces for empty query results', async () => { @@ -91,20 +93,20 @@ describe('listTraces', () => { const result = await listTraces(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.traces).toHaveLength(0); }); it('propagates errors from runInsightsQuery', async () => { mockRunInsightsQuery.mockResolvedValueOnce({ success: false, - error: 'Log group not found', + error: new ResourceNotFoundError('Log group not found'), }); const result = await listTraces(baseOptions); - expect(result.success).toBe(false); - expect(result.error).toBe('Log group not found'); + assert(!result.success); + expect(result.error.message).toBe('Log group not found'); }); it('passes correct log group name and default limit', async () => { diff --git a/src/cli/operations/traces/get-trace.ts b/src/cli/operations/traces/get-trace.ts index a87f10a65..153e67f5c 100644 --- a/src/cli/operations/traces/get-trace.ts +++ b/src/cli/operations/traces/get-trace.ts @@ -1,3 +1,5 @@ +import { ResourceNotFoundError, ValidationError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; import { runInsightsQuery } from './insights-query'; import type { @@ -23,9 +25,12 @@ async function fetchSpans( traceId: string, startTime?: number, endTime?: number -): Promise<{ success: boolean; spans?: CloudWatchSpanRecord[]; error?: string }> { +): Promise> { if (!TRACE_ID_PATTERN.test(traceId)) { - return { success: false, error: 'Invalid trace ID format. Expected a hex string (e.g., abc123def456).' }; + return { + success: false, + error: new ValidationError('Invalid trace ID format. Expected a hex string (e.g., abc123def456).'), + }; } const result = await runInsightsQuery({ @@ -50,7 +55,7 @@ async function fetchSpans( if (!result.success) return { success: false, error: result.error }; - const spans: CloudWatchSpanRecord[] = (result.rows ?? []) + const spans: CloudWatchSpanRecord[] = result.rows .filter(row => row.traceId && row.spanId) .map(row => ({ traceId: row.traceId!, @@ -82,7 +87,10 @@ export async function fetchTraceRecords(options: FetchTraceRecordsOptions): Prom const { region, runtimeId, traceId, includeSpans } = options; if (!TRACE_ID_PATTERN.test(traceId)) { - return { success: false, error: 'Invalid trace ID format. Expected a hex string (e.g., abc123def456).' }; + return { + success: false, + error: new ValidationError('Invalid trace ID format. Expected a hex string (e.g., abc123def456).'), + }; } const [recordsResult, spansResult] = await Promise.all([ @@ -103,10 +111,10 @@ export async function fetchTraceRecords(options: FetchTraceRecordsOptions): Prom return { success: false, error: recordsResult.error }; } - const traceData = recordsResult.rows ?? []; + const traceData = recordsResult.rows; - 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 && (!spansResult || !spansResult.success || spansResult.spans.length === 0)) { + return { success: false, error: new ResourceNotFoundError(`No trace data found for trace ID: ${traceId}`) }; } const records: CloudWatchTraceRecord[] = traceData.map(entry => { @@ -129,11 +137,9 @@ export async function fetchTraceRecords(options: FetchTraceRecordsOptions): Prom return record; }); - const result: FetchTraceRecordsResult = { success: true, records }; - - if (spansResult?.success && spansResult.spans) { - result.spans = spansResult.spans; - } + const result: FetchTraceRecordsResult = spansResult?.success + ? { success: true, records, spans: spansResult.spans } + : { success: true, records }; return result; } @@ -146,7 +152,10 @@ export async function getTrace(options: GetTraceOptions): Promise { diff --git a/src/cli/operations/traces/insights-query.ts b/src/cli/operations/traces/insights-query.ts index 5a4da2031..5d9c30b3a 100644 --- a/src/cli/operations/traces/insights-query.ts +++ b/src/cli/operations/traces/insights-query.ts @@ -1,3 +1,5 @@ +import { ResourceNotFoundError, TimeoutError, toError } from '../../../lib'; +import type { Result } from '../../../lib/result'; import { getCredentialProvider } from '../../aws'; import { CloudWatchLogsClient, GetQueryResultsCommand, StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'; @@ -11,11 +13,7 @@ export interface InsightsQueryOptions { endTime?: number; } -export interface InsightsQueryResult { - success: boolean; - rows?: Record[]; - error?: string; -} +export type InsightsQueryResult = Result<{ rows: Record[] }>; async function pollQueryResults(client: CloudWatchLogsClient, queryId: string): Promise { for (let i = 0; i < 60; i++) { @@ -26,7 +24,7 @@ async function pollQueryResults(client: CloudWatchLogsClient, queryId: string): if (status === 'Complete' || status === 'Failed' || status === 'Cancelled') { if (status !== 'Complete') { - return { success: false, error: `Query ${status.toLowerCase()}` }; + return { success: false, error: new Error(`Query ${status.toLowerCase()}`) }; } const rows = (queryResults.results ?? []).map(row => { @@ -42,7 +40,7 @@ async function pollQueryResults(client: CloudWatchLogsClient, queryId: string): } } - return { success: false, error: 'Query timed out after 60 seconds' }; + return { success: false, error: new TimeoutError('Query timed out after 60 seconds') }; } export async function runInsightsQuery(options: InsightsQueryOptions): Promise { @@ -68,7 +66,7 @@ export async function runInsightsQuery(options: InsightsQueryOptions): Promise((acc, row) => { + const traces = result.rows.reduce((acc, row) => { if (row.traceId) { acc.push({ traceId: row.traceId, diff --git a/src/cli/operations/traces/types.ts b/src/cli/operations/traces/types.ts index fae88a83b..95afe1a1e 100644 --- a/src/cli/operations/traces/types.ts +++ b/src/cli/operations/traces/types.ts @@ -1,3 +1,5 @@ +import type { Result } from '../../../lib/result'; + export interface CloudWatchTraceRecord { '@timestamp': string; '@message': unknown; @@ -31,12 +33,10 @@ export interface FetchTraceRecordsOptions { includeSpans?: boolean; } -export interface FetchTraceRecordsResult { - success: boolean; - records?: CloudWatchTraceRecord[]; +export type FetchTraceRecordsResult = Result<{ + records: CloudWatchTraceRecord[]; spans?: CloudWatchSpanRecord[]; - error?: string; -} +}>; export interface GetTraceOptions { region: string; @@ -48,11 +48,7 @@ export interface GetTraceOptions { endTime?: number; } -export interface GetTraceResult { - success: boolean; - filePath?: string; - error?: string; -} +export type GetTraceResult = Result<{ filePath: string }>; export interface TraceEntry { traceId: string; @@ -70,8 +66,4 @@ export interface ListTracesOptions { endTime?: number; } -export interface ListTracesResult { - success: boolean; - traces?: TraceEntry[]; - error?: string; -} +export type ListTracesResult = Result<{ traces: TraceEntry[] }>; diff --git a/src/cli/primitives/ABTestPrimitive.ts b/src/cli/primitives/ABTestPrimitive.ts index dc1852c1f..7dc2a7fd4 100644 --- a/src/cli/primitives/ABTestPrimitive.ts +++ b/src/cli/primitives/ABTestPrimitive.ts @@ -1,8 +1,9 @@ -import { findConfigRoot } from '../../lib'; +import { ResourceNotFoundError, findConfigRoot, serializeResult, toError } from '../../lib'; +import type { Result } from '../../lib/result'; import type { ABTest } from '../../schema/schemas/primitives/ab-test'; import { ABTestSchema } from '../../schema/schemas/primitives/ab-test'; import { getErrorMessage } from '../errors'; -import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; import { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; @@ -66,17 +67,17 @@ export class ABTestPrimitive extends BasePrimitive { + async remove(testName: string, options?: { deleteGateway?: boolean }): Promise { try { const project = await this.readProjectSpec(); const index = (project.abTests ?? []).findIndex(t => t.name === testName); if (index === -1) { - return { success: false, error: `AB test "${testName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`AB test "${testName}" not found.`) }; } const removedTest = project.abTests[index]!; @@ -124,7 +125,7 @@ export class ABTestPrimitive extends BasePrimitive { + async remove(agentName: string): Promise { try { const project = await this.readProjectSpec(); const agentIndex = project.runtimes.findIndex(a => a.name === agentName); if (agentIndex === -1) { - return { success: false, error: `Agent "${agentName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Agent "${agentName}" not found.`) }; } // Remove agent (credentials preserved for potential reuse) @@ -164,8 +177,7 @@ export class AgentPrimitive extends BasePrimitive; + abstract remove(name: string): Promise; /** * Preview what will be removed. @@ -122,7 +123,7 @@ export abstract class BasePrimitive< process.exit(1); } - const result = await withCommandRunTelemetry, RemovalResult>( + const result = await withCommandRunTelemetry, Result>( `remove.${this.kind}`, {}, () => this.remove(cliOptions.name!) @@ -134,7 +135,7 @@ export abstract class BasePrimitive< resourceName: cliOptions.name, message: result.success ? `Removed ${this.label.toLowerCase()} '${cliOptions.name}'` : undefined, note: result.success ? SOURCE_CODE_NOTE : undefined, - error: !result.success ? result.error : undefined, + error: !result.success ? result.error.message : undefined, }) ); process.exit(result.success ? 0 : 1); diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index 77f26205b..92c798d8f 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -1,8 +1,9 @@ -import { findConfigRoot } from '../../lib'; +import { ResourceNotFoundError, findConfigRoot, serializeResult, toError } from '../../lib'; +import type { Result } from '../../lib/result'; import type { ConfigBundle } from '../../schema'; import { ConfigBundleSchema } from '../../schema'; import { getErrorMessage } from '../errors'; -import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { BasePrimitive } from './BasePrimitive'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; import type { Command } from '@commander-js/extra-typings'; @@ -37,17 +38,17 @@ export class ConfigBundlePrimitive extends BasePrimitive { + async remove(bundleName: string): Promise { try { const project = await this.readProjectSpec(); const index = (project.configBundles ?? []).findIndex(b => b.name === bundleName); if (index === -1) { - return { success: false, error: `Configuration bundle "${bundleName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Configuration bundle "${bundleName}" not found.`) }; } project.configBundles.splice(index, 1); @@ -55,7 +56,7 @@ export class ConfigBundlePrimitive extends BasePrimitive { + async remove(credentialName: string, options?: { force?: boolean }): Promise { try { const project = await this.readProjectSpec(); const credentialIndex = project.credentials.findIndex(c => c.name === credentialName); if (credentialIndex === -1) { - return { success: false, error: `Credential "${credentialName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Credential "${credentialName}" not found.`) }; } const credential = project.credentials[credentialIndex]!; @@ -96,7 +105,9 @@ export class CredentialPrimitive extends BasePrimitive t.name).join(', '); return { success: false, - error: `Credential "${credentialName}" is referenced by gateway target(s): ${targetList}. Use force to override.`, + error: new ConflictError( + `Credential "${credentialName}" is referenced by gateway target(s): ${targetList}. Use force to override.` + ), }; } @@ -115,8 +128,7 @@ export class CredentialPrimitive extends BasePrimitive { + async remove(evaluatorName: string): Promise { try { const project = await this.readProjectSpec(); const index = project.evaluators.findIndex(e => e.name === evaluatorName); if (index === -1) { - return { success: false, error: `Evaluator "${evaluatorName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Evaluator "${evaluatorName}" not found.`) }; } // Warn if referenced by online eval configs @@ -76,7 +78,9 @@ export class EvaluatorPrimitive extends BasePrimitive c.name).join(', '); return { success: false, - error: `Evaluator "${evaluatorName}" is referenced by online eval config(s): ${configNames}. Remove those references first.`, + error: new ConflictError( + `Evaluator "${evaluatorName}" is referenced by online eval config(s): ${configNames}. Remove those references first.` + ), }; } @@ -96,7 +100,7 @@ export class EvaluatorPrimitive extends BasePrimitive', 'Path to evaluator config JSON file (overrides --model, --instructions, --rating-scale) [non-interactive]' ) + .option('--kms-key-arn ', 'KMS key ARN for evaluator encryption (optional)') .option('--json', 'Output as JSON [non-interactive]') .action( async (cliOptions: { @@ -196,6 +201,7 @@ export class EvaluatorPrimitive extends BasePrimitive { if (!findConfigRoot()) { @@ -289,18 +295,25 @@ export class EvaluatorPrimitive extends BasePrimitive { + async remove(gatewayName: string): Promise { try { const project = await this.readProjectSpec(); const mcpSpec = extractMcpSpec(project); const gateway = mcpSpec.agentCoreGateways.find(g => g.name === gatewayName); if (!gateway) { - return { success: false, error: `Gateway "${gatewayName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Gateway "${gatewayName}" not found.`) }; } const newMcpSpec = this.computeRemovedGatewayMcpSpec(mcpSpec, gatewayName); @@ -86,8 +87,7 @@ export class GatewayPrimitive extends BasePrimitive { + async remove(name: string): Promise { // Find the target by name to get its gateway info const tools = await this.getRemovable(); const tool = tools.find(t => t.name === name); if (!tool) { - return { success: false, error: `Gateway target "${name}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Gateway target "${name}" not found.`) }; } return this.removeGatewayTarget(tool); } @@ -183,7 +192,7 @@ export class GatewayTargetPrimitive extends BasePrimitive { + async removeGatewayTarget(tool: RemovableGatewayTarget): Promise { try { const project = await this.readProjectSpec(); const mcpSpec = extractMcpSpec(project); @@ -195,11 +204,14 @@ export class GatewayTargetPrimitive extends BasePrimitive g.name === tool.gatewayName); if (!gateway) { - return { success: false, error: `Gateway "${tool.gatewayName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Gateway "${tool.gatewayName}" not found.`) }; } const target = gateway.targets.find(t => t.name === tool.name); if (!target) { - return { success: false, error: `Target "${tool.name}" not found in gateway "${tool.gatewayName}".` }; + return { + success: false, + error: new ResourceNotFoundError(`Target "${tool.name}" not found in gateway "${tool.gatewayName}".`), + }; } if (target.compute?.implementation && 'path' in target.compute.implementation) { toolPath = target.compute.implementation.path; @@ -223,8 +235,7 @@ export class GatewayTargetPrimitive extends BasePrimitive { + async remove(memoryName: string): Promise { try { const project = await this.readProjectSpec(); const memoryIndex = project.memories.findIndex(m => m.name === memoryName); if (memoryIndex === -1) { - return { success: false, error: `Memory "${memoryName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Memory "${memoryName}" not found.`) }; } project.memories.splice(memoryIndex, 1); @@ -101,8 +102,7 @@ export class MemoryPrimitive extends BasePrimitive { + async remove(configName: string): Promise { try { const project = await this.readProjectSpec(); const index = project.onlineEvalConfigs.findIndex(c => c.name === configName); if (index === -1) { - return { success: false, error: `Online eval config "${configName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Online eval config "${configName}" not found.`) }; } project.onlineEvalConfigs.splice(index, 1); @@ -52,7 +53,7 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive { + async remove(engineName: string): Promise { try { const project = await this.readProjectSpec(); const index = project.policyEngines.findIndex(e => e.name === engineName); if (index === -1) { - return { success: false, error: `Policy engine "${engineName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Policy engine "${engineName}" not found.`) }; } project.policyEngines.splice(index, 1); @@ -70,8 +71,7 @@ export class PolicyEnginePrimitive extends BasePrimitive 1) { return { success: false, - error: 'Only one of --statement, --source, or --generate can be provided.', + error: new ValidationError('Only one of --statement, --source, or --generate can be provided.'), }; } @@ -48,7 +49,7 @@ export class PolicyPrimitive extends BasePrimitive e.name === options.engine); if (!engine) { - return { success: false, error: `Policy engine "${options.engine}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Policy engine "${options.engine}" not found.`) }; } this.checkDuplicate(engine.policies, options.name, 'Policy'); @@ -57,11 +58,11 @@ export class PolicyPrimitive extends BasePrimitive to specify one.', + error: new ResourceNotFoundError( + 'No deployed gateway found. Policy generation requires a deployed gateway. Use --gateway to specify one.' + ), }; } @@ -123,7 +130,10 @@ export class PolicyPrimitive extends BasePrimitive { + async remove(nameOrCompositeKey: string, engineName?: string): Promise { try { const project = await this.readProjectSpec(); @@ -167,7 +177,9 @@ export class PolicyPrimitive extends BasePrimitive 1) { return { success: false, - error: `Policy "${resolvedPolicy}" exists in multiple engines: ${matchingEngines.map(e => e.name).join(', ')}. Use --engine to specify which one.`, + error: new ValidationError( + `Policy "${resolvedPolicy}" exists in multiple engines: ${matchingEngines.map(e => e.name).join(', ')}. Use --engine to specify which one.` + ), }; } } @@ -185,10 +197,12 @@ export class PolicyPrimitive extends BasePrimitive a.name === options.runtime); if (!runtime) { - return { success: false, error: `Runtime "${options.runtime}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Runtime "${options.runtime}" not found.`) }; } // Initialize endpoints dictionary if needed @@ -56,14 +64,14 @@ export class RuntimeEndpointPrimitive extends BasePrimitive deployedRuntime.runtimeVersion) { return { success: false, - error: `Version ${version} exceeds latest deployed version ${deployedRuntime.runtimeVersion} for runtime "${options.runtime}".`, + error: new ValidationError( + `Version ${version} exceeds latest deployed version ${deployedRuntime.runtimeVersion} for runtime "${options.runtime}".` + ), }; } } @@ -104,11 +114,11 @@ export class RuntimeEndpointPrimitive extends BasePrimitive { + async remove(name: string): Promise { try { const project = await this.readProjectSpec(); @@ -119,7 +129,7 @@ export class RuntimeEndpointPrimitive extends BasePrimitive r.name === runtimeName); if (!runtime?.endpoints?.[endpointName]) { - return { success: false, error: `Runtime endpoint "${name}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Runtime endpoint "${name}" not found.`) }; } delete runtime.endpoints[endpointName]; if (Object.keys(runtime.endpoints).length === 0) { @@ -141,9 +151,9 @@ export class RuntimeEndpointPrimitive extends BasePrimitive ({ writeProjectSpec = mockWriteProjectSpec; }, findConfigRoot: () => '/fake/root', + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, })); function makeProject(abTests: { name: string; gatewayRef?: string }[] = []) { @@ -121,7 +129,10 @@ describe('ABTestPrimitive', () => { const result = await primitive.add(validOptions); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('already exists') }), + }) ); }); @@ -130,7 +141,7 @@ describe('ABTestPrimitive', () => { const result = await primitive.add(validOptions); - expect(result).toEqual(expect.objectContaining({ success: false, error: 'disk read error' })); + expect(result).toEqual(expect.objectContaining({ success: false, error: new Error('disk read error') })); }); it('returns error when writeProjectSpec fails', async () => { @@ -139,7 +150,7 @@ describe('ABTestPrimitive', () => { const result = await primitive.add(validOptions); - expect(result).toEqual(expect.objectContaining({ success: false, error: 'disk write error' })); + expect(result).toEqual(expect.objectContaining({ success: false, error: new Error('disk write error') })); }); it('returns error when variant weights do not sum to 100', async () => { @@ -175,8 +186,8 @@ describe('ABTestPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('NonExistent'); - expect(result.error).toContain('not found'); + expect(result.error.message).toContain('NonExistent'); + expect(result.error.message).toContain('not found'); } }); @@ -187,7 +198,7 @@ describe('ABTestPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toBe('io error'); + expect(result.error.message).toBe('io error'); } }); diff --git a/src/cli/primitives/__tests__/BasePrimitive.test.ts b/src/cli/primitives/__tests__/BasePrimitive.test.ts index 830cd4244..f29252bf0 100644 --- a/src/cli/primitives/__tests__/BasePrimitive.test.ts +++ b/src/cli/primitives/__tests__/BasePrimitive.test.ts @@ -1,5 +1,6 @@ +import type { Result } from '../../../lib/result'; import { BasePrimitive } from '../BasePrimitive'; -import type { AddResult, AddScreenComponent, RemovableResource, RemovalPreview, RemovalResult } from '../types'; +import type { AddResult, AddScreenComponent, RemovableResource, RemovalPreview } from '../types'; import type { Command } from '@commander-js/extra-typings'; import { describe, expect, it } from 'vitest'; import { z } from 'zod'; @@ -14,7 +15,7 @@ class StubPrimitive extends BasePrimitive { return Promise.resolve({ success: true }); } - remove(_name: string): Promise { + remove(_name: string): Promise { return Promise.resolve({ success: true }); } diff --git a/src/cli/primitives/__tests__/EvaluatorPrimitive.test.ts b/src/cli/primitives/__tests__/EvaluatorPrimitive.test.ts index b41545d20..a521e6a78 100644 --- a/src/cli/primitives/__tests__/EvaluatorPrimitive.test.ts +++ b/src/cli/primitives/__tests__/EvaluatorPrimitive.test.ts @@ -11,6 +11,20 @@ vi.mock('../../../lib/index.js', () => ({ writeProjectSpec = mockWriteProjectSpec; }, findConfigRoot: () => '/fake/root', + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, + ConflictError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ConflictError'; + } + }, })); const validConfig: EvaluatorConfig = { @@ -108,7 +122,10 @@ describe('EvaluatorPrimitive', () => { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('already exists') }), + }) ); }); @@ -121,7 +138,7 @@ describe('EvaluatorPrimitive', () => { config: validConfig, }); - expect(result).toEqual(expect.objectContaining({ success: false, error: 'disk read error' })); + expect(result).toEqual(expect.objectContaining({ success: false, error: new Error('disk read error') })); }); }); @@ -145,8 +162,8 @@ describe('EvaluatorPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('NonExistent'); - expect(result.error).toContain('not found'); + expect(result.error.message).toContain('NonExistent'); + expect(result.error.message).toContain('not found'); } }); @@ -159,8 +176,8 @@ describe('EvaluatorPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('referenced by online eval config'); - expect(result.error).toContain('MyOnlineConfig'); + expect(result.error.message).toContain('referenced by online eval config'); + expect(result.error.message).toContain('MyOnlineConfig'); } expect(mockWriteProjectSpec).not.toHaveBeenCalled(); }); @@ -172,7 +189,7 @@ describe('EvaluatorPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toBe('io error'); + expect(result.error.message).toBe('io error'); } }); }); diff --git a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts index 5f136eabe..fb53e095d 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -34,6 +34,14 @@ vi.mock('../../../lib', () => { ConfigIO: MockConfigIO, findConfigRoot: vi.fn().mockReturnValue('/fake/root'), setEnvVar: vi.fn().mockResolvedValue(undefined), + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, }; }); diff --git a/src/cli/primitives/__tests__/OnlineEvalConfigPrimitive.test.ts b/src/cli/primitives/__tests__/OnlineEvalConfigPrimitive.test.ts index c81160a6c..81f13b40a 100644 --- a/src/cli/primitives/__tests__/OnlineEvalConfigPrimitive.test.ts +++ b/src/cli/primitives/__tests__/OnlineEvalConfigPrimitive.test.ts @@ -10,6 +10,14 @@ vi.mock('../../../lib/index.js', () => ({ writeProjectSpec = mockWriteProjectSpec; }, findConfigRoot: () => '/fake/root', + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, })); function makeProject( @@ -126,7 +134,10 @@ describe('OnlineEvalConfigPrimitive', () => { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('already exists') }), + }) ); }); @@ -140,7 +151,7 @@ describe('OnlineEvalConfigPrimitive', () => { samplingRate: 10, }); - expect(result).toEqual(expect.objectContaining({ success: false, error: 'no project' })); + expect(result).toEqual(expect.objectContaining({ success: false, error: new Error('no project') })); }); }); @@ -169,8 +180,8 @@ describe('OnlineEvalConfigPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('NonExistent'); - expect(result.error).toContain('not found'); + expect(result.error.message).toContain('NonExistent'); + expect(result.error.message).toContain('not found'); } }); @@ -181,7 +192,7 @@ describe('OnlineEvalConfigPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toBe('io error'); + expect(result.error.message).toBe('io error'); } }); }); diff --git a/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts b/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts index 46fe426f5..1dc4d9e73 100644 --- a/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts +++ b/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts @@ -14,6 +14,26 @@ vi.mock('../../../lib/index.js', () => ({ readDeployedState = mockReadDeployedState; }, findConfigRoot: () => '/fake/root', + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, + ConflictError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ConflictError'; + } + }, + ValidationError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ValidationError'; + } + }, })); function makeProject( @@ -87,7 +107,12 @@ describe('RuntimeEndpointPrimitive', () => { endpoint: 'prod', }); - expect(result).toEqual(expect.objectContaining({ success: false, error: expect.stringContaining('not found') })); + expect(result).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('not found') }), + }) + ); }); it('returns error when endpoint already exists', async () => { @@ -100,7 +125,10 @@ describe('RuntimeEndpointPrimitive', () => { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('already exists') }), + }) ); }); @@ -134,7 +162,10 @@ describe('RuntimeEndpointPrimitive', () => { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('positive integer') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('positive integer') }), + }) ); }); @@ -181,7 +212,10 @@ describe('RuntimeEndpointPrimitive', () => { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('exceeds latest deployed version') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('exceeds latest deployed version') }), + }) ); }); }); @@ -223,7 +257,12 @@ describe('RuntimeEndpointPrimitive', () => { const result = await primitive.remove('MyRuntime/nonexistent'); - expect(result).toEqual(expect.objectContaining({ success: false, error: expect.stringContaining('not found') })); + expect(result).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('not found') }), + }) + ); }); it('cleans up empty endpoints dict after removing last endpoint', async () => { diff --git a/src/cli/primitives/index.ts b/src/cli/primitives/index.ts index 3f69da1ed..05d00f869 100644 --- a/src/cli/primitives/index.ts +++ b/src/cli/primitives/index.ts @@ -24,4 +24,4 @@ export { getPrimitive, } from './registry'; export { SOURCE_CODE_NOTE } from './constants'; -export type { AddResult, AddScreenComponent, RemovableResource, RemovalPreview, RemovalResult } from './types'; +export type { AddResult, AddScreenComponent, RemovableResource, RemovalPreview, Result } from './types'; diff --git a/src/cli/primitives/types.ts b/src/cli/primitives/types.ts index 73842c972..90885ed7f 100644 --- a/src/cli/primitives/types.ts +++ b/src/cli/primitives/types.ts @@ -1,14 +1,11 @@ -import type { RemovalPreview, RemovalResult } from '../operations/remove/types'; +import type { Result } from '../../lib/result'; +import type { RemovalPreview } from '../operations/remove/types'; import type { ComponentType } from 'react'; -/** - * Result of an add operation. - * Use the generic parameter to type extra fields on the success branch: - * AddResult<{ agentName: string }> → success branch has typed agentName - */ -export type AddResult = Record> = - | ({ success: true; message?: string } & T) - | { success: false; error: string }; +export type { Result }; + +/** @deprecated Use Result directly */ +export type AddResult = Record> = Result; /** * Represents a resource that can be removed. @@ -21,7 +18,7 @@ export interface RemovableResource { /** * Re-export removal types from shared types. */ -export type { RemovalPreview, RemovalResult }; +export type { RemovalPreview }; /** * Screen component type for TUI add flows. diff --git a/src/cli/telemetry/README.md b/src/cli/telemetry/README.md index 7d83ef6dc..49f98bbcb 100644 --- a/src/cli/telemetry/README.md +++ b/src/cli/telemetry/README.md @@ -58,15 +58,16 @@ async function withCommandRunTelemetry` (from `src/lib/result.ts`) **Behavior:** -- On success (`{ success: true }`): records success telemetry with `attrs`, returns the result. -- On failure (`{ success: false, error }`): records failure telemetry, returns the result to the caller. -- On throw: records failure telemetry, returns `{ success: false, error }` so callers don't leak unhandled rejections. +- On success: records `attrs` with `exit_reason: 'success'`, returns the result. +- On failure/throw: records `attrs` with `exit_reason: 'failure'`, returns `{ success: false, error }`. - If telemetry is unavailable: runs `fn()` untracked. +Since `attrs` are passed upfront, they are always recorded — even on failure. + **Example with attributes:** ```ts @@ -95,6 +96,22 @@ await runCliCommand('add.widget', !!options.json, async () => { }); ``` +To record attrs on failure, pass `knownAttrs` as the fourth argument: + +```ts +const knownAttrs = { widget_type: standardize(WidgetType, options.type), count: options.items.length }; +await runCliCommand( + 'add.widget', + !!options.json, + async () => { + const result = await widgetPrimitive.add(options); + if (!result.success) throw new Error(result.error); + return knownAttrs; + }, + knownAttrs +); +``` + ## Key Points - Telemetry never crashes the CLI — `standardize()` falls back gracefully, `resilientParse` defaults invalid fields to diff --git a/src/cli/telemetry/__tests__/client.test.ts b/src/cli/telemetry/__tests__/client.test.ts index 96adebafc..cfba2a0c2 100644 --- a/src/cli/telemetry/__tests__/client.test.ts +++ b/src/cli/telemetry/__tests__/client.test.ts @@ -173,5 +173,54 @@ describe('TelemetryClient', () => { exit_reason: 'cancel', }); }); + + it('records fallbackAttrs on failure when provided', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + await expect( + client.withCommandRun( + 'create', + async () => { + throw new Error('validation failed'); + }, + { + language: 'python', + framework: 'strands', + model_provider: 'bedrock', + memory: 'none', + protocol: 'http', + build: 'codezip', + agent_type: 'create', + network_mode: 'public', + has_agent: true, + } + ) + ).rejects.toThrow('validation failed'); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs).toMatchObject({ + exit_reason: 'failure', + error_name: 'UnknownError', + language: 'python', + framework: 'strands', + model_provider: 'bedrock', + has_agent: 'true', + }); + }); + + it('records empty attrs on failure when fallbackAttrs not provided', async () => { + const sink = new InMemorySink(); + const client = new TelemetryClient(sink); + + await expect( + client.withCommandRun('deploy', async () => { + throw new Error('boom'); + }) + ).rejects.toThrow('boom'); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.attrs.language).toBeUndefined(); + }); }); }); diff --git a/src/cli/telemetry/cli-command-run.ts b/src/cli/telemetry/cli-command-run.ts index f04347dc5..2fabd39fe 100644 --- a/src/cli/telemetry/cli-command-run.ts +++ b/src/cli/telemetry/cli-command-run.ts @@ -1,9 +1,9 @@ +import type { Result } from '../../lib/result'; import { getErrorMessage } from '../errors'; import { TelemetryClientAccessor } from './client-accessor.js'; import type { Command, CommandAttrs } from './schemas/command-run.js'; -// TODO: Replace with a generic Result type that preserves the original error object. -export type OperationResult = { success: true } | { success: false; error: string }; +export type OperationResult = Result; async function getTelemetryClient() { try { @@ -31,18 +31,22 @@ export async function withCommandRunTelemetry { - result = await fn(); - if (!result.success) throw new Error(result.error); - return attrs; - }); + await client.withCommandRun( + command, + async () => { + result = await fn(); + if (!result.success) throw result.error; + return attrs; + }, + attrs + ); } catch (e) { // withCommandRun re-throws after recording failure telemetry. // If result was set, fn() returned a failure result — return it directly. // If not, fn() itself threw — convert to a failure result so callers // that don't wrap in try/catch (e.g. TUI hooks) don't leak unhandled rejections. if (!result) { - return { success: false, error: getErrorMessage(e) } as R; + return { success: false, error: e instanceof Error ? e : new Error(getErrorMessage(e)) } as R; } } return result!; @@ -52,11 +56,13 @@ export async function withCommandRunTelemetry( command: C, json: boolean, - fn: () => Promise> + fn: () => Promise>, + knownAttrs?: Partial> ): Promise { try { const client = await getTelemetryClient(); @@ -64,7 +70,7 @@ export async function runCliCommand( await fn(); process.exit(0); } - await client.withCommandRun(command, fn); + await client.withCommandRun(command, fn, knownAttrs); process.exit(0); } catch (error) { if (json) { diff --git a/src/cli/telemetry/client.ts b/src/cli/telemetry/client.ts index 91dffd94f..2341231ea 100644 --- a/src/cli/telemetry/client.ts +++ b/src/cli/telemetry/client.ts @@ -26,7 +26,8 @@ export class TelemetryClient { */ async withCommandRun( command: C, - fn: () => CommandAttrs | typeof CANCELLED | Promise | typeof CANCELLED> + fn: () => CommandAttrs | typeof CANCELLED | Promise | typeof CANCELLED>, + fallbackAttrs?: Partial> ): Promise { const start = performance.now(); try { @@ -43,7 +44,7 @@ export class TelemetryClient { error_name: classifyError(err), is_user_error: isUserError(err), }; - this.recordCommandRun(command, failureResult, {}, Math.round(performance.now() - start)); + this.recordCommandRun(command, failureResult, fallbackAttrs ?? {}, Math.round(performance.now() - start)); throw err; } finally { try { @@ -75,9 +76,8 @@ export class TelemetryClient { // Validate command attrs resiliently: invalid fields default to 'unknown' // instead of dropping the entire metric. - // On failure/cancel the callback attrs are empty so validation is skipped. const validatedAttrs = - result.exit_reason !== 'failure' && result.exit_reason !== 'cancel' + Object.keys(attrs as Record).length > 0 ? resilientParse(COMMAND_SCHEMAS[command], attrs as Record) : attrs; diff --git a/src/cli/telemetry/error-classification.ts b/src/cli/telemetry/error-classification.ts index 0cc5d060d..1f5627699 100644 --- a/src/cli/telemetry/error-classification.ts +++ b/src/cli/telemetry/error-classification.ts @@ -9,6 +9,7 @@ const CONFIG_ERRORS = new Set([ 'ConfigReadError', 'ConfigWriteError', 'ConfigParseError', + 'ValidationError', ]); const PACKAGING_ERRORS = new Set([ 'PackagingError', @@ -16,11 +17,13 @@ const PACKAGING_ERRORS = new Set([ 'MissingProjectFileError', 'UnsupportedLanguageError', 'ArtifactSizeError', + 'DependencyCheckError', ]); const CREDENTIAL_ERRORS = new Set([ 'AwsCredentialsError', 'AccessDeniedException', 'AccessDenied', + 'AccessDeniedError', 'ExpiredToken', 'ExpiredTokenException', 'TokenRefreshRequired', @@ -36,6 +39,8 @@ const SERVICE_ERRORS = new Set([ 'ValidationException', 'ConflictException', 'ResourceAlreadyExistsException', + 'ResourceNotFoundError', + 'ConflictError', ]); const USER_CATEGORIES = new Set(['ConfigError', 'CredentialsError', 'ProjectError']); diff --git a/src/cli/telemetry/schemas/__tests__/command-run.test.ts b/src/cli/telemetry/schemas/__tests__/command-run.test.ts index 110df1284..2df12d271 100644 --- a/src/cli/telemetry/schemas/__tests__/command-run.test.ts +++ b/src/cli/telemetry/schemas/__tests__/command-run.test.ts @@ -47,7 +47,7 @@ describe('COMMAND_SCHEMAS', () => { gateway_target_count: 3, policy_engine_count: 0, policy_count: 0, - has_diff: true, + mode: 'diff', }; expect(COMMAND_SCHEMAS.deploy.parse(attrs)).toEqual(attrs); }); @@ -64,7 +64,7 @@ describe('COMMAND_SCHEMAS', () => { gateway_target_count: 0, policy_engine_count: 0, policy_count: 0, - has_diff: false, + mode: 'deploy', }) ).toThrow(); }); @@ -81,7 +81,7 @@ describe('COMMAND_SCHEMAS', () => { gateway_target_count: 0, policy_engine_count: 0, policy_count: 0, - has_diff: false, + mode: 'deploy', }) ).toThrow(); }); diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index 7bcc4e555..bf79f42eb 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -41,7 +41,7 @@ const CreateAttrs = safeSchema({ memory: Memory, protocol: Protocol, build: Build, - agent_type: z.enum(['create', 'import']), + agent_type: AgentType, network_mode: NetworkMode, has_agent: z.boolean(), }); @@ -100,7 +100,7 @@ const DeployAttrs = safeSchema({ gateway_target_count: Count, policy_engine_count: Count, policy_count: Count, - has_diff: z.boolean(), + mode: z.enum(['deploy', 'dry-run', 'diff']), }); const DevAttrs = safeSchema({ diff --git a/src/cli/tui/hooks/__tests__/useRemove.test.tsx b/src/cli/tui/hooks/__tests__/useRemove.test.tsx index ef2a68dda..e0ed7f9ac 100644 --- a/src/cli/tui/hooks/__tests__/useRemove.test.tsx +++ b/src/cli/tui/hooks/__tests__/useRemove.test.tsx @@ -281,7 +281,7 @@ describe('useRemoveAgent', () => { }); it('calls removeAgent and shows failure result', async () => { - mockAgentRemove.mockResolvedValue({ success: false, error: 'Not found' }); + mockAgentRemove.mockResolvedValue({ success: false, error: new Error('Not found') }); const { lastFrame } = render(); await delay(); diff --git a/src/cli/tui/hooks/useCreateABTest.ts b/src/cli/tui/hooks/useCreateABTest.ts index e54666074..89c36715e 100644 --- a/src/cli/tui/hooks/useCreateABTest.ts +++ b/src/cli/tui/hooks/useCreateABTest.ts @@ -43,7 +43,7 @@ export function useCreateABTest() { enableOnCreate: config.enableOnCreate, }); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create AB test'); + throw new Error(addResult.error?.message ?? 'Failed to create AB test'); } setStatus({ state: 'success' }); return { ok: true as const, testName: config.name }; @@ -59,7 +59,7 @@ export function useCreateABTest() { try { const addResult = await abTestPrimitive.addTargetBased(config); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create target-based AB test'); + throw new Error(addResult.error?.message ?? 'Failed to create target-based AB test'); } setStatus({ state: 'success' }); return { ok: true as const, testName: config.name }; diff --git a/src/cli/tui/hooks/useCreateConfigBundle.ts b/src/cli/tui/hooks/useCreateConfigBundle.ts index 864501eed..f243b8e4d 100644 --- a/src/cli/tui/hooks/useCreateConfigBundle.ts +++ b/src/cli/tui/hooks/useCreateConfigBundle.ts @@ -25,7 +25,7 @@ export function useCreateConfigBundle() { commitMessage: config.commitMessage, }); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create configuration bundle'); + throw new Error(addResult.error?.message ?? 'Failed to create configuration bundle'); } setStatus({ state: 'success' }); return { ok: true as const, bundleName: config.name }; diff --git a/src/cli/tui/hooks/useCreateEvaluator.ts b/src/cli/tui/hooks/useCreateEvaluator.ts index 087eca479..86cf7c4be 100644 --- a/src/cli/tui/hooks/useCreateEvaluator.ts +++ b/src/cli/tui/hooks/useCreateEvaluator.ts @@ -8,6 +8,7 @@ interface CreateEvaluatorConfig { name: string; level: string; config: EvaluatorConfig; + kmsKeyArn?: string; } export function useCreateEvaluator() { @@ -29,10 +30,11 @@ export function useCreateEvaluator() { name: config.name, level: config.level as 'SESSION' | 'TRACE' | 'TOOL_CALL', config: config.config, + kmsKeyArn: config.kmsKeyArn, }) ); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create evaluator'); + throw new Error(addResult.error?.message ?? 'Failed to create evaluator'); } setStatus({ state: 'success' }); return { ok: true as const, evaluatorName: config.name, codePath: addResult.codePath }; diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index 60900bc63..db541c76e 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -53,7 +53,7 @@ export function useCreateGateway() { }) ); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create gateway'); + throw new Error(addResult.error?.message ?? 'Failed to create gateway'); } const result: CreateGatewayResult = { name: config.name }; setStatus({ state: 'success', result }); diff --git a/src/cli/tui/hooks/useCreateMemory.ts b/src/cli/tui/hooks/useCreateMemory.ts index f125b2615..cf891c4f9 100644 --- a/src/cli/tui/hooks/useCreateMemory.ts +++ b/src/cli/tui/hooks/useCreateMemory.ts @@ -45,7 +45,7 @@ export function useCreateMemory() { }) ); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create memory'); + throw new Error(addResult.error?.message ?? 'Failed to create memory'); } // Read back the memory object const configIO = new ConfigIO(); diff --git a/src/cli/tui/hooks/useCreateOnlineEval.ts b/src/cli/tui/hooks/useCreateOnlineEval.ts index 381e6d136..0fa53f56b 100644 --- a/src/cli/tui/hooks/useCreateOnlineEval.ts +++ b/src/cli/tui/hooks/useCreateOnlineEval.ts @@ -38,7 +38,7 @@ export function useCreateOnlineEval() { }) ); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create online eval config'); + throw new Error(addResult.error?.message ?? 'Failed to create online eval config'); } setStatus({ state: 'success' }); return { ok: true as const, configName: config.name }; diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index abdeda2d9..9400ea2ad 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -1,6 +1,7 @@ +import type { Result } from '../../../lib/result'; import type { ResourceType } from '../../commands/remove/types'; import { RemoveLogger } from '../../logging'; -import type { RemovableGatewayTarget, RemovalPreview, RemovalResult } from '../../operations/remove'; +import type { RemovableGatewayTarget, RemovalPreview } from '../../operations/remove'; import type { RemovableCredential } from '../../primitives/CredentialPrimitive'; import type { RemovableMemory } from '../../primitives/MemoryPrimitive'; import type { RemovablePolicyResource } from '../../primitives/PolicyPrimitive'; @@ -62,7 +63,7 @@ function useRemovableResources(loader: () => Promise) { * All useRemove* hooks delegate to this. */ function useRemoveResource( - removeFn: (id: TIdentifier) => Promise, + removeFn: (id: TIdentifier) => Promise, resourceType: ResourceType, getResourceName: (id: TIdentifier) => string ) { @@ -76,7 +77,7 @@ function useRemoveResource( const remove = useCallback(async (id: TIdentifier, preview?: RemovalPreview): Promise => { setState({ isLoading: true, result: null }); - const result = await withCommandRunTelemetry, RemovalResult>( + const result = await withCommandRunTelemetry, Result>( `remove.${resourceTypeRef.current}`, {}, () => removeFnRef.current(id) @@ -89,7 +90,7 @@ function useRemoveResource( resourceType: resourceTypeRef.current, resourceName: getNameRef.current(id), }); - logger.logRemoval(preview, result.success, result.success ? undefined : result.error); + logger.logRemoval(preview, result.success, result.success ? undefined : result.error?.message); logPath = logger.getAbsoluteLogPath(); setLogFilePath(logPath); } @@ -297,10 +298,10 @@ export function useRemovalPreview() { interface RemovalState { isLoading: boolean; - result: RemovalResult | null; + result: Result | null; } -type RemoveResult = RemovalResult & { logFilePath?: string }; +type RemoveResult = Result & { logFilePath?: string }; export function useRemoveAgent() { return useRemoveResource( diff --git a/src/cli/tui/screens/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index aeac9e442..2c3539dd4 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -1,4 +1,12 @@ -import { APP_DIR, ConfigIO, NoProjectError, findConfigRoot, setEnvVar } from '../../../../lib'; +import { + APP_DIR, + AgentAlreadyExistsError, + ConfigIO, + NoProjectError, + type Result, + findConfigRoot, + setEnvVar, +} from '../../../../lib'; import type { AgentEnvSpec, DirectoryPath, FilePath } from '../../../../schema'; import { type PythonSetupResult, setupPythonProject } from '../../../operations'; import { createConfigBundleForAgent } from '../../../operations/agent/config-bundle-defaults'; @@ -165,7 +173,7 @@ export function useAddAgent() { () => addAgentInner(config) ); if (!result.success) { - return { ok: false, error: result.error }; + return { ok: false, error: result.error.message }; } return result.outcome; } finally { @@ -180,26 +188,24 @@ export function useAddAgent() { return { addAgent, isLoading, reset }; } -type AddAgentInnerResult = - | { success: true; outcome: AddAgentCreateResult | AddAgentByoResult } - | { success: false; error: string }; +type AddAgentInnerResult = Result<{ outcome: AddAgentCreateResult | AddAgentByoResult }>; async function addAgentInner(config: AddAgentConfig): Promise { const configBaseDir = findConfigRoot(); if (!configBaseDir) { - return { success: false, error: new NoProjectError().message }; + return { success: false, error: new NoProjectError() }; } const configIO = new ConfigIO({ baseDir: configBaseDir }); if (!configIO.configExists('project')) { - return { success: false, error: new NoProjectError().message }; + return { success: false, error: new NoProjectError() }; } const project = await configIO.readProjectSpec(); const existingAgent = project.runtimes.find(agent => agent.name === config.name); if (existingAgent) { - return { success: false, error: `Agent "${config.name}" already exists in this project.` }; + return { success: false, error: new AgentAlreadyExistsError(config.name) }; } let outcome: AddAgentCreateResult | AddAgentByoResult | AddAgentError; @@ -212,7 +218,7 @@ async function addAgentInner(config: AddAgentConfig): Promise { if (phase !== 'running') return; - const run = async () => { + const attrs = { + language: standardize(Language, addAgentConfig?.language ?? 'Python'), + framework: standardize(Framework, addAgentConfig?.framework), + model_provider: standardize(ModelProvider, addAgentConfig?.modelProvider), + memory: standardize(MemoryEnum, addAgentConfig?.memory ?? 'none'), + protocol: standardize(Protocol, addAgentConfig?.protocol ?? 'HTTP'), + build: standardize(Build, addAgentConfig?.buildType ?? 'CodeZip'), + agent_type: standardize(AgentType, addAgentConfig?.agentType ?? 'create'), + network_mode: standardize(NetworkMode, addAgentConfig?.networkMode ?? 'PUBLIC'), + has_agent: addAgentConfig !== null, + }; + + const run = async (): Promise<{ success: true } | { success: false; error: Error }> => { // Project root is now cwd/projectName (creating a new directory) const projectRoot = join(cwd, projectName); const configBaseDir = join(projectRoot, CONFIG_DIR); @@ -244,7 +276,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { logger.endStep('error', errMsg); updateStep(stepIndex, { status: 'error', error: errMsg }); logger.finalize(false); - return; + return { success: false, error: new Error(errMsg) }; } // Step: Add agent to project (if addAgentConfig is set) @@ -363,7 +395,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { sessionStorageMountPath: addAgentConfig.sessionStorageMountPath, }); if (!importResult.success) { - throw new Error(importResult.error ?? 'Import failed'); + throw new Error(importResult.error?.message ?? 'Import failed'); } } else { // BYO path: just write config to project (no file generation) @@ -432,7 +464,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { logger.endStep('error', errMsg); updateStep(stepIndex, { status: 'error', error: errMsg }); logger.finalize(false); - return; + return { success: false, error: new Error(errMsg) }; } // Step: Set up Python environment (if Python and create path) @@ -474,7 +506,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { logger.endStep('error', errMsg); updateStep(stepIndex, { status: 'error', error: errMsg }); logger.finalize(false); - return; + return { success: false, error: new Error(errMsg) }; } // Step: Initialize git repository @@ -486,7 +518,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { logger.endStep('error', gitResult.message); updateStep(stepIndex, { status: 'error', error: gitResult.message }); logger.finalize(false); - return; + return { success: false, error: new GitInitError(gitResult.message ?? 'Git initialization failed') }; } else if (gitResult.status === 'skipped') { logger.endStep('warn', gitResult.message); updateStep(stepIndex, { status: 'success', warn: gitResult.message }); @@ -497,6 +529,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { logger.finalize(true); setPhase('complete'); + return { success: true }; } catch (err) { // Top-level catch - find current running step and mark as error const errMsg = getErrorMessage(err); @@ -509,10 +542,11 @@ export function useCreateFlow(cwd: string): CreateFlowState { } return prev; }); + return { success: false, error: new Error(errMsg) }; } }; - void run(); + void withCommandRunTelemetry('create', attrs, run); // eslint-disable-next-line react-hooks/exhaustive-deps }, [phase]); diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index b27b3e528..637a5d832 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -11,6 +11,7 @@ import { parsePolicyEngineOutputs, parsePolicyOutputs, } from '../../../cloudformation'; +import { DEFAULT_DEPLOY_ATTRS, computeDeployAttrs } from '../../../commands/deploy/utils.js'; import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from '../../../errors'; import { ExecLogger } from '../../../logging'; import { performStackTeardown, setupTransactionSearch } from '../../../operations/deploy'; @@ -22,6 +23,7 @@ import { } from '../../../operations/deploy/post-deploy-config-bundles'; import { setupHttpGateways } from '../../../operations/deploy/post-deploy-http-gateways'; import { enableOnlineEvalConfigs } from '../../../operations/deploy/post-deploy-online-evals'; +import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; import { type StackDiffSummary, type Step, @@ -550,7 +552,9 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (deployStep.status !== 'pending') return; if (!cdkToolkitWrapper) return; - const run = async () => { + const attrs = context ? computeDeployAttrs(context.projectSpec, 'deploy') : { ...DEFAULT_DEPLOY_ATTRS }; + + const run = async (): Promise<{ success: true } | { success: false; error: Error }> => { // Run diff before deploy to capture pre-deploy differences if (!isDiffRunningRef.current) { isDiffRunningRef.current = true; @@ -623,7 +627,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (targetName) { const teardown = await performStackTeardown(targetName); if (!teardown.success) { - throw new Error(`Stack teardown failed: ${teardown.error}`); + throw new Error(`Stack teardown failed: ${teardown.error.message}`); } } } else { @@ -648,8 +652,8 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState agentNames, hasGateways, }); - if (tsResult.error) { - logger.log(`Transaction search setup warning: ${tsResult.error}`, 'warn'); + if (!tsResult.success) { + logger.log(`Transaction search setup warning: ${tsResult.error.message}`, 'warn'); } else { setDeployNotes(prev => [ ...prev, @@ -669,6 +673,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // Mark both steps as success (in case CFn events were never received) setPublishAssetsStep(prev => ({ ...prev, status: 'success' })); setDeployStep(prev => ({ ...prev, status: 'success' })); + return { success: true } as const; } catch (err) { const errorMsg = getErrorMessage(err); @@ -700,6 +705,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState error: logger.getFailureMessage('Publish assets'), })); } + return { success: false, error: err instanceof Error ? err : new Error(errorMsg) } as const; } finally { // Disable verbose output and clear callback after deploy switchableIoHost?.setVerbose(false); @@ -709,7 +715,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } }; - void run(); + void withCommandRunTelemetry('deploy', attrs, run); }, [ preflight.phase, cdkToolkitWrapper, @@ -734,7 +740,11 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (diffStep.status !== 'pending') return; if (!cdkToolkitWrapper) return; - const run = async () => { + const attrs = context + ? computeDeployAttrs(context.projectSpec, 'diff') + : { ...DEFAULT_DEPLOY_ATTRS, mode: 'diff' as const }; + + const run = async (): Promise<{ success: true } | { success: false; error: Error }> => { setDiffStep(prev => ({ ...prev, status: 'running' })); setShouldStartDeploy(false); setDiffSummaries([]); @@ -755,6 +765,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState logger.endStep('success'); logger.finalize(true); setDiffStep(prev => ({ ...prev, status: 'success' })); + return { success: true }; } catch (err) { const errorMsg = getErrorMessage(err); logger.endStep('error', errorMsg); @@ -769,6 +780,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState status: 'error', error: logger.getFailureMessage('Run CDK diff'), })); + return { success: false, error: err instanceof Error ? err : new Error(errorMsg) }; } finally { switchableIoHost?.setVerbose(false); switchableIoHost?.setOnRawMessage(null); @@ -776,7 +788,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } }; - void run(); + void withCommandRunTelemetry('deploy', attrs, run); }, [ diffMode, preflight.phase, diff --git a/src/cli/tui/screens/eval/EvalScreen.tsx b/src/cli/tui/screens/eval/EvalScreen.tsx index 3f57999d9..eb33557fc 100644 --- a/src/cli/tui/screens/eval/EvalScreen.tsx +++ b/src/cli/tui/screens/eval/EvalScreen.tsx @@ -339,7 +339,7 @@ export function EvalScreen({ onExit }: EvalScreenProps) { } const result = handleListEvalRuns({}); if (!result.success) { - setState({ phase: 'error', runs: [], error: result.error ?? 'Unknown error' }); + setState({ phase: 'error', runs: [], error: result.error?.message ?? 'Unknown error' }); return; } setState({ phase: 'loaded', runs: result.runs ?? [], error: null }); diff --git a/src/cli/tui/screens/evaluator/AddEvaluatorScreen.tsx b/src/cli/tui/screens/evaluator/AddEvaluatorScreen.tsx index 2c4a2f465..b4294f978 100644 --- a/src/cli/tui/screens/evaluator/AddEvaluatorScreen.tsx +++ b/src/cli/tui/screens/evaluator/AddEvaluatorScreen.tsx @@ -1,5 +1,5 @@ import type { EvaluationLevel, EvaluatorConfig } from '../../../../schema'; -import { EvaluatorNameSchema, isValidBedrockModelId } from '../../../../schema'; +import { EvaluatorNameSchema, isValidBedrockModelId, isValidKmsKeyArn } from '../../../../schema'; import type { SelectableItem } from '../../components'; import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; import { HELP_TEXT } from '../../constants'; @@ -91,6 +91,7 @@ export function AddEvaluatorScreen({ onComplete, onExit, existingEvaluatorNames const isRatingScaleCustomStep = wizard.step === 'ratingScale-custom'; const isLambdaArnStep = wizard.step === 'lambda-arn'; const isTimeoutStep = wizard.step === 'timeout'; + const isKmsKeyArnStep = wizard.step === 'kms-key-arn'; const isConfirmStep = wizard.step === 'confirm'; const evaluatorTypeNav = useListNavigation({ @@ -163,6 +164,8 @@ export function AddEvaluatorScreen({ onComplete, onExit, existingEvaluatorNames // Build confirm fields based on evaluator type const confirmFields = useMemo(() => { + const kmsField = wizard.config.kmsKeyArn ? [{ label: 'KMS Key ARN', value: wizard.config.kmsKeyArn }] : []; + if (wizard.evaluatorType === 'llm-as-a-judge') { const llm = wizard.config.config.llmAsAJudge!; return [ @@ -175,6 +178,7 @@ export function AddEvaluatorScreen({ onComplete, onExit, existingEvaluatorNames value: llm.instructions.length > 60 ? llm.instructions.slice(0, 60) + '...' : llm.instructions, }, { label: 'Rating Scale', value: formatRatingScale(llm.ratingScale) }, + ...kmsField, ]; } @@ -187,6 +191,7 @@ export function AddEvaluatorScreen({ onComplete, onExit, existingEvaluatorNames { label: 'Code', value: managed.codeLocation }, { label: 'Entrypoint', value: managed.entrypoint }, { label: 'Timeout', value: `${managed.timeoutSeconds}s` }, + ...kmsField, ]; } @@ -197,6 +202,7 @@ export function AddEvaluatorScreen({ onComplete, onExit, existingEvaluatorNames { label: 'Name', value: wizard.config.name }, { label: 'Level', value: wizard.config.level }, { label: 'Lambda ARN', value: external.lambdaArn }, + ...kmsField, ]; }, [wizard.evaluatorType, wizard.codeBasedType, wizard.config]); @@ -374,6 +380,21 @@ export function AddEvaluatorScreen({ onComplete, onExit, existingEvaluatorNames /> )} + {isKmsKeyArnStep && ( + wizard.goBack()} + customValidation={value => + value === '' || + isValidKmsKeyArn(value) || + 'Must be a valid KMS key ARN (e.g. arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012)' + } + /> + )} + {isConfirmStep && } diff --git a/src/cli/tui/screens/evaluator/types.ts b/src/cli/tui/screens/evaluator/types.ts index 4b19bf85d..229e8b8d5 100644 --- a/src/cli/tui/screens/evaluator/types.ts +++ b/src/cli/tui/screens/evaluator/types.ts @@ -20,12 +20,14 @@ export type AddEvaluatorStep = | 'ratingScale-custom' | 'lambda-arn' | 'timeout' + | 'kms-key-arn' | 'confirm'; export interface AddEvaluatorConfig { name: string; level: EvaluationLevel; config: EvaluatorConfig; + kmsKeyArn?: string; } export const EVALUATOR_STEP_LABELS: Record = { @@ -41,6 +43,7 @@ export const EVALUATOR_STEP_LABELS: Record = { 'ratingScale-custom': 'Scale', 'lambda-arn': 'Lambda', timeout: 'Timeout', + 'kms-key-arn': 'KMS Key', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/evaluator/useAddEvaluatorWizard.ts b/src/cli/tui/screens/evaluator/useAddEvaluatorWizard.ts index 1bc54f50b..ff31f2009 100644 --- a/src/cli/tui/screens/evaluator/useAddEvaluatorWizard.ts +++ b/src/cli/tui/screens/evaluator/useAddEvaluatorWizard.ts @@ -22,6 +22,7 @@ const LLM_STEPS: AddEvaluatorStep[] = [ 'model', 'instructions', 'ratingScale', + 'kms-key-arn', 'confirm', ]; const CODE_MANAGED_STEPS: AddEvaluatorStep[] = [ @@ -30,6 +31,7 @@ const CODE_MANAGED_STEPS: AddEvaluatorStep[] = [ 'name', 'level', 'timeout', + 'kms-key-arn', 'confirm', ]; const CODE_EXTERNAL_STEPS: AddEvaluatorStep[] = [ @@ -38,6 +40,7 @@ const CODE_EXTERNAL_STEPS: AddEvaluatorStep[] = [ 'name', 'level', 'lambda-arn', + 'kms-key-arn', 'confirm', ]; @@ -80,6 +83,7 @@ export function useAddEvaluatorWizard() { const [lambdaArn, setLambdaArnState] = useState(''); const [timeout, setTimeoutState] = useState(DEFAULT_CODE_TIMEOUT); const [customRatingScaleType, setCustomRatingScaleType] = useState('numerical'); + const [kmsKeyArn, setKmsKeyArnState] = useState(''); const [step, setStep] = useState('evaluator-type'); const steps = useMemo(() => getSteps(evaluatorType, codeBasedType), [evaluatorType, codeBasedType]); @@ -109,11 +113,13 @@ export function useAddEvaluatorWizard() { // Build the final config based on current state const config: AddEvaluatorConfig = useMemo(() => { + const kms = kmsKeyArn || undefined; if (evaluatorType === 'llm-as-a-judge') { return { name, level, config: { llmAsAJudge: llmConfig }, + ...(kms && { kmsKeyArn: kms }), }; } @@ -126,6 +132,7 @@ export function useAddEvaluatorWizard() { external: { lambdaArn }, }, }, + ...(kms && { kmsKeyArn: kms }), }; } @@ -143,8 +150,9 @@ export function useAddEvaluatorWizard() { }, }, }, + ...(kms && { kmsKeyArn: kms }), }; - }, [evaluatorType, codeBasedType, name, level, llmConfig, lambdaArn, timeout]); + }, [evaluatorType, codeBasedType, name, level, llmConfig, lambdaArn, timeout, kmsKeyArn]); const selectEvaluatorType = useCallback((type: EvaluatorTypeId) => { setEvaluatorType(type); @@ -256,6 +264,15 @@ export function useAddEvaluatorWizard() { [nextStep] ); + const setKmsKeyArn = useCallback( + (arn: string) => { + setKmsKeyArnState(arn); + const next = nextStep('kms-key-arn'); + if (next) setStep(next); + }, + [nextStep] + ); + const reset = useCallback(() => { setEvaluatorType('code-based'); setCodeBasedType('managed'); @@ -264,6 +281,7 @@ export function useAddEvaluatorWizard() { setLlmConfig(getDefaultLlmConfig().llmAsAJudge!); setLambdaArnState(''); setTimeoutState(DEFAULT_CODE_TIMEOUT); + setKmsKeyArnState(''); setStep('evaluator-type'); }, []); @@ -288,6 +306,7 @@ export function useAddEvaluatorWizard() { setCustomRatingScale, setLambdaArn, setTimeout, + setKmsKeyArn, reset, }; } diff --git a/src/cli/tui/screens/identity/useCreateIdentity.ts b/src/cli/tui/screens/identity/useCreateIdentity.ts index c9293665b..031f30d42 100644 --- a/src/cli/tui/screens/identity/useCreateIdentity.ts +++ b/src/cli/tui/screens/identity/useCreateIdentity.ts @@ -25,7 +25,7 @@ export function useCreateIdentity() { () => credentialPrimitive.add(config) ); if (!result.success) { - throw new Error(result.error ?? 'Failed to create credential'); + throw new Error(result.error?.message ?? 'Failed to create credential'); } // Read back the credential object const configIO = new ConfigIO(); diff --git a/src/cli/tui/screens/import/ImportFlow.tsx b/src/cli/tui/screens/import/ImportFlow.tsx index ed82962d3..0a0843f7e 100644 --- a/src/cli/tui/screens/import/ImportFlow.tsx +++ b/src/cli/tui/screens/import/ImportFlow.tsx @@ -151,7 +151,7 @@ export function ImportFlow({ onBack, onNavigate }: ImportFlowProps) { Name: {result.resourceName} - {result.resourceId && ( + {'resourceId' in result && result.resourceId && ( ID: {result.resourceId} diff --git a/src/cli/tui/screens/import/ImportProgressScreen.tsx b/src/cli/tui/screens/import/ImportProgressScreen.tsx index 3771ffbab..a0f0229cf 100644 --- a/src/cli/tui/screens/import/ImportProgressScreen.tsx +++ b/src/cli/tui/screens/import/ImportProgressScreen.tsx @@ -59,9 +59,9 @@ export function ImportProgressScreen({ onSuccess(result); } else { setSteps(prev => - prev.map(s => (s.status === 'running' ? { ...s, status: 'error', error: result.error } : s)) + prev.map(s => (s.status === 'running' ? { ...s, status: 'error', error: result.error.message } : s)) ); - onError(result.error ?? 'Import failed'); + onError(result.error.message ?? 'Import failed'); } } else { // Starter toolkit @@ -75,9 +75,9 @@ export function ImportProgressScreen({ onSuccess(result); } else { setSteps(prev => - prev.map(s => (s.status === 'running' ? { ...s, status: 'error', error: result.error } : s)) + prev.map(s => (s.status === 'running' ? { ...s, status: 'error', error: result.error.message } : s)) ); - onError(result.error ?? 'Import failed'); + onError(result.error.message ?? 'Import failed'); } } }; diff --git a/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx b/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx index 03961473e..bfc0e7ed1 100644 --- a/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx +++ b/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx @@ -156,7 +156,7 @@ export function OnlineEvalDashboard({ onExit }: OnlineEvalDashboardProps) { setState(prev => ({ ...prev, phase: 'toggling' })); void handlePauseResume({ name: item.name }, action).then(result => { if (!result.success) { - setState(prev => ({ ...prev, phase: 'loaded', error: result.error ?? 'Toggle failed' })); + setState(prev => ({ ...prev, phase: 'loaded', error: result.error?.message ?? 'Toggle failed' })); return; } return fetchDashboardConfigs().then(configs => { diff --git a/src/cli/tui/screens/policy/AddPolicyFlow.tsx b/src/cli/tui/screens/policy/AddPolicyFlow.tsx index 8238d84c8..54a180dd0 100644 --- a/src/cli/tui/screens/policy/AddPolicyFlow.tsx +++ b/src/cli/tui/screens/policy/AddPolicyFlow.tsx @@ -139,7 +139,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD () => policyEnginePrimitive.add({ name: engineName }) ); if (!result.success) { - setFlow({ name: 'error', message: result.error }); + setFlow({ name: 'error', message: result.error.message }); return; } setEngineNames(prev => [...prev, engineName]); @@ -184,7 +184,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD setPolicyNames(prev => [...prev, config.name]); setFlow({ name: 'policy-success', policyName: config.name, engineName: config.engine }); } else { - setFlow({ name: 'error', message: result.error }); + setFlow({ name: 'error', message: result.error.message }); } }, []); diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index b28344df3..b044d5464 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -33,7 +33,12 @@ type FlowState = recommendationId?: string; region?: string; } - | { name: 'results'; result: RunRecommendationCommandResult; config: RecommendationWizardConfig; filePath?: string } + | { + name: 'results'; + result: Extract; + config: RecommendationWizardConfig; + filePath?: string; + } | { name: 'creds-error'; message: string } | { name: 'error'; message: string; logFilePath?: string }; @@ -194,13 +199,17 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { setFlow(prev => { if (prev.name !== 'running') return prev; const steps = prev.steps.map(s => - s.status === 'running' ? { ...s, status: 'error' as const, error: result.error } : s + s.status === 'running' ? { ...s, status: 'error' as const, error: result.error.message } : s ); return { ...prev, steps }; }); await new Promise(resolve => setTimeout(resolve, 2000)); if (cancelled) return; - setFlow({ name: 'error', message: result.error ?? 'Recommendation failed', logFilePath: result.logFilePath }); + setFlow({ + name: 'error', + message: result.error?.message ?? 'Recommendation failed', + logFilePath: result.logFilePath, + }); return; } @@ -333,7 +342,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { // ───────────────────────────────────────────────────────────────────────────── interface ResultsViewProps { - result: RunRecommendationCommandResult; + result: Extract; config: RecommendationWizardConfig; filePath?: string; onRunAnother: () => void; @@ -369,7 +378,7 @@ function ResultsView({ result, config, filePath, onRunAnother, onExit }: Results message: `New bundle version (${applyResult.newVersionId}) created with recommended changes. Local config updated.`, }); } else { - setApplyStatus({ applied: false, message: applyResult.error ?? 'Unknown error' }); + setApplyStatus({ applied: false, message: applyResult.error?.message ?? 'Unknown error' }); } } catch (err) { setApplyStatus({ applied: false, message: getErrorMessage(err) }); diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index 7001fde27..696107486 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -331,7 +331,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'agent-success', agentName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-agent', agentName, preview: result.preview }); @@ -353,7 +353,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'gateway-success', gatewayName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-gateway', gatewayName, preview: result.preview }); @@ -375,7 +375,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'tool-success', toolName: tool.name }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-gateway-target', tool, preview: result.preview }); @@ -397,7 +397,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'memory-success', memoryName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-memory', memoryName, preview: result.preview }); @@ -419,7 +419,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'identity-success', identityName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-identity', identityName, preview: result.preview }); @@ -441,7 +441,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'evaluator-success', evaluatorName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-evaluator', evaluatorName, preview: result.preview }); @@ -463,7 +463,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'online-eval-success', configName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-online-eval', configName, preview: result.preview }); @@ -485,7 +485,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'policy-engine-success', engineName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-policy-engine', engineName, preview: result.preview }); @@ -510,7 +510,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'policy-success', policyName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-policy', compositeKey, policyName, preview: result.preview }); @@ -532,7 +532,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'config-bundle-success', bundleName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-config-bundle', bundleName, preview: result.preview }); @@ -554,7 +554,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'ab-test-success', testName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-ab-test', testName, preview: result.preview }); @@ -576,7 +576,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'runtime-endpoint-success', endpointName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-runtime-endpoint', endpointName, preview: result.preview }); @@ -662,7 +662,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'agent-success', agentName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -678,7 +678,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'gateway-success', gatewayName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -694,7 +694,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'tool-success', toolName: tool.name, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -710,7 +710,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'memory-success', memoryName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -726,7 +726,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'identity-success', identityName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -742,7 +742,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'evaluator-success', evaluatorName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -758,7 +758,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'online-eval-success', configName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -774,7 +774,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'policy-engine-success', engineName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -790,7 +790,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'policy-success', policyName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -806,7 +806,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'config-bundle-success', bundleName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -822,7 +822,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'ab-test-success', testName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -838,7 +838,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'runtime-endpoint-success', endpointName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, diff --git a/src/cli/tui/screens/remove/useRemoveFlow.ts b/src/cli/tui/screens/remove/useRemoveFlow.ts index 96ddd2f64..36062d520 100644 --- a/src/cli/tui/screens/remove/useRemoveFlow.ts +++ b/src/cli/tui/screens/remove/useRemoveFlow.ts @@ -1,7 +1,7 @@ import { ConfigIO, getWorkingDirectory } from '../../../../lib'; +import type { Result } from '../../../../lib/result'; import { findStack } from '../../../cloudformation/stack-discovery'; import { getErrorMessage } from '../../../errors'; -import type { RemovalResult } from '../../../operations/remove/types'; import { createDefaultProjectSpec } from '../../../project'; import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; import { type Step, areStepsComplete, hasStepError } from '../../components'; @@ -151,7 +151,7 @@ export function useRemoveFlow({ force, dryRun }: RemoveFlowOptions): RemoveFlowS const res = await withCommandRunTelemetry( 'remove.all', {}, - (): Promise => + (): Promise => withMinDuration(async () => { const configIO = new ConfigIO(); @@ -164,7 +164,7 @@ export function useRemoveFlow({ force, dryRun }: RemoveFlowOptions): RemoveFlowS return { success: true }; }) ); - if (!res.success) throw new Error(res.error); + if (!res.success) throw res.error; updateStep(0, { status: 'success' }); } catch (err) { updateStep(0, { status: 'error', error: getErrorMessage(err) }); diff --git a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx index 38c03c150..659d2034e 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -260,7 +260,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { setFlow(prev => { if (prev.name !== 'running') return prev; const steps = prev.steps.map(s => - s.status === 'running' ? { ...s, status: 'error' as const, error: result.error } : s + s.status === 'running' ? { ...s, status: 'error' as const, error: result.error.message } : s ); return { ...prev, steps }; }); @@ -268,7 +268,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { if (cancelled) return; setFlow({ name: 'error', - message: result.error ?? 'Batch evaluation failed', + message: result.error?.message ?? 'Batch evaluation failed', logFilePath: result.logFilePath, }); return; diff --git a/src/cli/tui/screens/run-eval/RunEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunEvalFlow.tsx index 6b4ced516..231589b2d 100644 --- a/src/cli/tui/screens/run-eval/RunEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunEvalFlow.tsx @@ -20,7 +20,7 @@ type FlowState = | { name: 'loading' } | { name: 'wizard'; data: RunEvalFlowData } | { name: 'running'; config: RunEvalConfig } - | { name: 'results'; result: RunEvalResult; run: EvalRunResult } + | { name: 'results'; result: RunEvalResult; run: EvalRunResult; filePath: string } | { name: 'creds-error'; message: string } | { name: 'error'; message: string }; @@ -145,12 +145,12 @@ export function RunEvalFlow({ onExit, onViewRuns }: RunEvalFlowProps) { if (cancelled) return; - if (!result.success || !result.run) { - setFlow({ name: 'error', message: result.error ?? 'Evaluation failed' }); + if (!result.success) { + setFlow({ name: 'error', message: result.error.message }); return; } - setFlow({ name: 'results', result, run: result.run }); + setFlow({ name: 'results', result, run: result.run, filePath: result.filePath }); } catch (err) { if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); } @@ -196,7 +196,7 @@ export function RunEvalFlow({ onExit, onViewRuns }: RunEvalFlowProps) { return ( setFlow({ name: 'loading' })} onViewRuns={onViewRuns} onExit={onExit} diff --git a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx index 77c07b2e8..77f818124 100644 --- a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx +++ b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx @@ -95,7 +95,7 @@ export function AddRuntimeEndpointFlow({ }); return; } - setFlow({ name: 'error', message: result.error ?? 'Unknown error' }); + setFlow({ name: 'error', message: result.error?.message ?? 'Unknown error' }); }); }, []); diff --git a/src/cli/tui/screens/status/useStatusFlow.ts b/src/cli/tui/screens/status/useStatusFlow.ts index 2260e1e82..d54ea6e79 100644 --- a/src/cli/tui/screens/status/useStatusFlow.ts +++ b/src/cli/tui/screens/status/useStatusFlow.ts @@ -99,7 +99,7 @@ export function useStatusFlow() { ...prev, phase: 'ready', statusesLoaded: true, - statusesError: result.error, + statusesError: result.error?.message, })); return; } diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts index f03c2281a..2acd167c8 100644 --- a/src/lib/errors/index.ts +++ b/src/lib/errors/index.ts @@ -1 +1,2 @@ export * from './config'; +export * from './types'; diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts new file mode 100644 index 000000000..f0a05106c --- /dev/null +++ b/src/lib/errors/types.ts @@ -0,0 +1,94 @@ +/** + * Error indicating no agentcore project was found in the working directory. + */ +export class NoProjectError extends Error { + constructor(message?: string) { + super(message ?? 'No agentcore project found. Run "agentcore create" first.'); + this.name = 'NoProjectError'; + } +} + +/** + * Error thrown when an agent with the same name already exists. + */ +export class AgentAlreadyExistsError extends Error { + constructor(agentName: string) { + super(`An agent named "${agentName}" already exists in the schema.`); + this.name = 'AgentAlreadyExistsError'; + } +} + +/** + * Error indicating an AWS permissions failure (AccessDenied / AccessDeniedException). + */ +export class AccessDeniedError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'AccessDeniedError'; + } +} + +/** + * Error indicating missing system dependencies required for an operation. + */ +export class DependencyCheckError extends Error { + constructor(errors: string[]) { + super(errors.join('\n')); + this.name = 'DependencyCheckError'; + } +} + +/** + * Error indicating git repository initialization failed. + */ +export class GitInitError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitInitError'; + } +} + +/** + * Error indicating a referenced resource could not be found. + */ +export class ResourceNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'ResourceNotFoundError'; + } +} + +export class ValidationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ValidationError'; + } +} + +/** + * Converts an unknown thrown value to an Error instance. + * Use in catch blocks to ensure the error field is always an Error object. + */ +export function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +/** + * Error indicating a resource conflict (e.g., already exists). + */ +export class ConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConflictError'; + } +} + +/** + * Error indicating an operation timed out or exceeded retry limits. + */ +export class TimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'TimeoutError'; + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts index d21baec86..5be75106d 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -28,3 +28,5 @@ export * from './utils'; // Schema I/O utilities export * from './schemas/io'; export * from './time-constants'; +export { serializeResult } from './result'; +export type { Result } from './result'; diff --git a/src/lib/result.test.ts b/src/lib/result.test.ts new file mode 100644 index 000000000..9ecdd2bad --- /dev/null +++ b/src/lib/result.test.ts @@ -0,0 +1,19 @@ +import { serializeResult } from './result'; +import { describe, expect, it } from 'vitest'; + +describe('serializeResult', () => { + it('passes through success results unchanged', () => { + const result = { success: true as const, name: 'test' }; + expect(serializeResult(result)).toEqual({ success: true, name: 'test' }); + }); + + it('converts error.message to string on failure', () => { + const result = { success: false as const, error: new Error('something broke') }; + expect(serializeResult(result)).toEqual({ success: false, error: 'something broke' }); + }); + + it('preserves extra fields on failure branch', () => { + const result = { success: false as const, error: new Error('fail'), logPath: '/tmp/log' }; + expect(serializeResult(result)).toEqual({ success: false, error: 'fail', logPath: '/tmp/log' }); + }); +}); diff --git a/src/lib/result.ts b/src/lib/result.ts new file mode 100644 index 000000000..abec578c3 --- /dev/null +++ b/src/lib/result.ts @@ -0,0 +1,30 @@ +/** + * Discriminated union for fallible operations, inspired by Rust's Result. + * + * Success branch spreads T onto the result; failure branch carries an Error. + * E extends Error so callers always get stack traces, cause chains, and instanceof narrowing. + * + * @example + * Result // { success: true } | { success: false; error: Error } + * Result<{ name: string }> // { success: true; name: string } | { success: false; error: Error } + * Result<{ name: string }, ValidationError> // { success: true; name: string } | { success: false; error: ValidationError } + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type Result = {}, E extends Error = Error> = + | ({ success: true } & T) + | { success: false; error: E }; + +/** + * Converts a Result object to a JSON-serializable form. + * Error objects have non-enumerable properties, so JSON.stringify produces `{}`. + * This replaces the `error` field with the error message string. + */ +export function serializeResult>( + result: ({ success: true } & T) | ({ success: false; error: Error } & Record) +): Record { + if (!result.success) { + const { error, ...rest } = result; + return { ...rest, error: error.message }; + } + return result; +} diff --git a/src/lib/schemas/io/__tests__/config-io.test.ts b/src/lib/schemas/io/__tests__/config-io.test.ts index 4b583c502..99e885e28 100644 --- a/src/lib/schemas/io/__tests__/config-io.test.ts +++ b/src/lib/schemas/io/__tests__/config-io.test.ts @@ -1,6 +1,6 @@ +import { NoProjectError } from '../../../errors'; import { ConfigNotFoundError, ConfigParseError, ConfigValidationError } from '../../../errors/config.js'; import { ConfigIO } from '../config-io.js'; -import { NoProjectError } from '../path-resolver.js'; import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { mkdir, rm, unlink, writeFile } from 'node:fs/promises'; diff --git a/src/lib/schemas/io/__tests__/path-resolver.test.ts b/src/lib/schemas/io/__tests__/path-resolver.test.ts index cbf9c8288..11303fbfc 100644 --- a/src/lib/schemas/io/__tests__/path-resolver.test.ts +++ b/src/lib/schemas/io/__tests__/path-resolver.test.ts @@ -1,5 +1,5 @@ +import { NoProjectError } from '../../../errors'; import { - NoProjectError, PathResolver, findConfigRoot, findProjectRoot, diff --git a/src/lib/schemas/io/config-io.ts b/src/lib/schemas/io/config-io.ts index a62b48e75..25841f4d8 100644 --- a/src/lib/schemas/io/config-io.ts +++ b/src/lib/schemas/io/config-io.ts @@ -12,9 +12,10 @@ import { ConfigReadError, ConfigValidationError, ConfigWriteError, + NoProjectError, } from '../../errors'; import { detectAwsAccount } from '../../utils'; -import { NoProjectError, type PathConfig, PathResolver, findConfigRoot } from './path-resolver'; +import { type PathConfig, PathResolver, findConfigRoot } from './path-resolver'; import { loadSharedConfigFiles } from '@smithy/shared-ini-file-loader'; import { existsSync } from 'fs'; import { mkdir, readFile, writeFile } from 'fs/promises'; diff --git a/src/lib/schemas/io/index.ts b/src/lib/schemas/io/index.ts index 212468ffe..e5915541e 100644 --- a/src/lib/schemas/io/index.ts +++ b/src/lib/schemas/io/index.ts @@ -7,7 +7,6 @@ export { getSessionProjectRoot, getWorkingDirectory, requireConfigRoot, - NoProjectError, type PathConfig, } from './path-resolver'; export { ConfigIO, createConfigIO, getSchemaUrlForVersion } from './config-io'; diff --git a/src/lib/schemas/io/path-resolver.ts b/src/lib/schemas/io/path-resolver.ts index 9737c4a2b..81f7e254e 100644 --- a/src/lib/schemas/io/path-resolver.ts +++ b/src/lib/schemas/io/path-resolver.ts @@ -1,20 +1,10 @@ import { CLI_LOGS_DIR, CLI_SYSTEM_DIR, CONFIG_DIR, CONFIG_FILES as _CONFIG_FILES } from '../../constants'; +import { NoProjectError } from '../../errors'; import { existsSync } from 'fs'; import { dirname, join } from 'path'; -// Re-export for backward compatibility export const CONFIG_FILES = _CONFIG_FILES; -/** - * Error thrown when no AgentCore project is found. - */ -export class NoProjectError extends Error { - constructor(message?: string) { - super(message ?? 'No agentcore project found. Run "agentcore create" first.'); - this.name = 'NoProjectError'; - } -} - /** * Get the working directory where the user invoked the CLI. * diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 10d164a2c..eac076ec1 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -11,7 +11,12 @@ import { AgentEnvSpecSchema } from './agent-env'; import { AgentCoreGatewaySchema, AgentCoreGatewayTargetSchema, AgentCoreMcpRuntimeToolSchema } from './mcp'; import { ABTestSchema } from './primitives/ab-test'; import { ConfigBundleSchema } from './primitives/config-bundle'; -import { EvaluationLevelSchema, EvaluatorConfigSchema, EvaluatorNameSchema } from './primitives/evaluator'; +import { + EvaluationLevelSchema, + EvaluatorConfigSchema, + EvaluatorNameSchema, + KmsKeyArnSchema, +} from './primitives/evaluator'; import { HttpGatewaySchema } from './primitives/http-gateway'; import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, @@ -45,7 +50,13 @@ export type { ManagedCodeBasedConfig, RatingScale, } from './primitives/evaluator'; -export { BedrockModelIdSchema, isValidBedrockModelId, EvaluatorNameSchema } from './primitives/evaluator'; +export { + BedrockModelIdSchema, + isValidBedrockModelId, + EvaluatorNameSchema, + KMS_KEY_ARN_PATTERN, + isValidKmsKeyArn, +} from './primitives/evaluator'; export { ConfigBundleSchema }; export type { ComponentConfiguration, ComponentConfigurationMap, ConfigBundle } from './primitives/config-bundle'; export { ConfigBundleNameSchema, ComponentConfigurationMapSchema } from './primitives/config-bundle'; @@ -210,6 +221,7 @@ export const EvaluatorSchema = z.object({ level: EvaluationLevelSchema, description: z.string().optional(), config: EvaluatorConfigSchema, + kmsKeyArn: KmsKeyArnSchema.optional(), tags: TagsSchema.optional(), }); diff --git a/src/schema/schemas/primitives/evaluator.ts b/src/schema/schemas/primitives/evaluator.ts index aa4f41cb4..97d772d52 100644 --- a/src/schema/schemas/primitives/evaluator.ts +++ b/src/schema/schemas/primitives/evaluator.ts @@ -119,3 +119,25 @@ export const EvaluatorConfigSchema = z }); export type EvaluatorConfig = z.infer; + +// ============================================================================ +// KMS Key ARN Validation +// ============================================================================ + +/** + * Pattern for KMS key ARNs accepted by the AgentCore Evaluation service. + * Matches key ARNs across all AWS partitions with a 36-char UUID key ID. + * Alias ARNs are not supported by the service for evaluator encryption. + */ +export const KMS_KEY_ARN_PATTERN = /^arn:[^:]+:kms:[a-zA-Z0-9-]*:[0-9]{12}:key\/[a-zA-Z0-9-]{36}$/; + +export const KmsKeyArnSchema = z + .string() + .regex( + KMS_KEY_ARN_PATTERN, + 'Must be a valid KMS key ARN (e.g. arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012)' + ); + +export function isValidKmsKeyArn(value: string): boolean { + return KMS_KEY_ARN_PATTERN.test(value); +} From 6ee50c4e1031bcfca62a988b77b59d6c27cc9486 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Wed, 20 May 2026 17:14:46 -0400 Subject: [PATCH 16/27] feat(workflows): unify Slack issue-notification payload to 20-key schema (#193) Match the public-repo schema upgrade for COE AI-7. The shared Slack workflow now branches on event_type and consumes pr_*/comment_* variables for closed-PR-comment alerts. To keep the schema reliable across repos, every issue_opened payload sends the same 20 keys with the new fields empty. Also switches to injection-safe env: + toJSON() for attacker-controlled fields (title, body, login). COE AI-7: https://quip-amazon.com/SmCwABMBwzgH --- .../workflows/slack-issue-notification.yml | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/.github/workflows/slack-issue-notification.yml b/.github/workflows/slack-issue-notification.yml index 630b978ae..89d3be642 100644 --- a/.github/workflows/slack-issue-notification.yml +++ b/.github/workflows/slack-issue-notification.yml @@ -4,21 +4,50 @@ on: issues: types: [opened] +permissions: {} + jobs: notify-slack: runs-on: ubuntu-latest steps: - name: Send issue details to Slack + # Attacker-controlled fields are passed through env: rather than + # interpolated into the YAML payload, to prevent workflow injection. + # Schema is uniform across event types: every workflow sends the + # same 20 keys so Slack-side branching on event_type is reliable. + # For issue_opened, the issue_* fields carry the data and the + # pr_*/comment_* fields are empty. + env: + REPOSITORY: ${{ github.repository }} + CREATED_AT: ${{ github.event.issue.created_at }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_URL: ${{ github.event.issue.html_url }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + ISSUE_BODY: ${{ github.event.issue.body }} + LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }} uses: slackapi/slack-github-action@v2.1.1 with: webhook: ${{ secrets.SLACK_WEBHOOK_URL }} webhook-type: webhook-trigger payload: | - issue_title: "${{ github.event.issue.title }}" - issue_number: "${{ github.event.issue.number }}" - issue_url: "${{ github.event.issue.html_url }}" - issue_author: "${{ github.event.issue.user.login }}" - issue_body: ${{ toJSON(github.event.issue.body) }} - repository: "${{ github.repository }}" - labels: "${{ join(github.event.issue.labels.*.name, ', ') }}" - created_at: "${{ github.event.issue.created_at }}" + event_type: "issue_opened" + repository: "${{ env.REPOSITORY }}" + created_at: "${{ env.CREATED_AT }}" + issue_number: "${{ env.ISSUE_NUMBER }}" + issue_title: ${{ toJSON(env.ISSUE_TITLE) }} + issue_url: "${{ env.ISSUE_URL }}" + issue_author: "${{ env.ISSUE_AUTHOR }}" + issue_body: ${{ toJSON(env.ISSUE_BODY) }} + labels: ${{ toJSON(env.LABELS) }} + pr_number: "" + pr_title: "" + pr_url: "" + pr_author: "" + pr_state: "" + pr_closed_at: "" + pr_merged_at: "" + comment_id: "" + comment_url: "" + comment_author: "" + comment_body: "" From 1c0111698b0810bef77725b64e5fedc02dfbe2ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 12:53:40 -0400 Subject: [PATCH 17/27] ci: bump the github-actions group across 1 directory with 5 updates (#180) Bumps the github-actions group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `4` | `6` | | [actions/create-github-app-token](https://github.com/actions/create-github-app-token) | `1` | `3` | | [aws-actions/aws-secretsmanager-get-secrets](https://github.com/aws-actions/aws-secretsmanager-get-secrets) | `2` | `3` | | [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `2` | `3` | | [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action) | `2.1.1` | `3.0.3` | Updates `actions/checkout` from 4 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) Updates `actions/create-github-app-token` from 1 to 3 - [Release notes](https://github.com/actions/create-github-app-token/releases) - [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/create-github-app-token/compare/v1...v3) Updates `aws-actions/aws-secretsmanager-get-secrets` from 2 to 3 - [Release notes](https://github.com/aws-actions/aws-secretsmanager-get-secrets/releases) - [Commits](https://github.com/aws-actions/aws-secretsmanager-get-secrets/compare/v2...v3) Updates `softprops/action-gh-release` from 2 to 3 - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) Updates `slackapi/slack-github-action` from 2.1.1 to 3.0.3 - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Changelog](https://github.com/slackapi/slack-github-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/slackapi/slack-github-action/compare/v2.1.1...v3.0.3) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/create-github-app-token dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: aws-actions/aws-secretsmanager-get-secrets dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: slackapi/slack-github-action dependency-version: 3.0.3 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agent-restricted.yml | 2 +- .github/workflows/ci-failure-issue.yml | 2 +- .github/workflows/cleanup-pr-tarballs.yml | 2 +- .github/workflows/e2e-tests-full.yml | 4 ++-- .github/workflows/e2e-tests.yml | 4 ++-- .github/workflows/pr-tarball.yml | 2 +- .github/workflows/release-main-and-preview.yml | 8 ++++---- .github/workflows/release.yml | 2 +- .github/workflows/slack-issue-notification.yml | 2 +- .github/workflows/slack-open-prs-notification.yml | 2 +- .github/workflows/strands-command.yml | 2 +- .github/workflows/sync-from-public.yml | 8 ++++---- .github/workflows/sync-preview.yml | 2 +- 13 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/agent-restricted.yml b/.github/workflows/agent-restricted.yml index d229919a4..c53838a9c 100644 --- a/.github/workflows/agent-restricted.yml +++ b/.github/workflows/agent-restricted.yml @@ -68,7 +68,7 @@ jobs: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/ci-failure-issue.yml b/.github/workflows/ci-failure-issue.yml index 0cbb430a1..8a3eeb369 100644 --- a/.github/workflows/ci-failure-issue.yml +++ b/.github/workflows/ci-failure-issue.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/cleanup-pr-tarballs.yml b/.github/workflows/cleanup-pr-tarballs.yml index 10a35be2c..2d898348d 100644 --- a/.github/workflows/cleanup-pr-tarballs.yml +++ b/.github/workflows/cleanup-pr-tarballs.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/e2e-tests-full.yml b/.github/workflows/e2e-tests-full.yml index 46bbf0d52..bdbcff2e4 100644 --- a/.github/workflows/e2e-tests-full.yml +++ b/.github/workflows/e2e-tests-full.yml @@ -50,7 +50,7 @@ jobs: id: aws run: echo "account_id=$(aws sts get-caller-identity --query Account --output text)" >> "$GITHUB_OUTPUT" - name: Get API keys from Secrets Manager - uses: aws-actions/aws-secretsmanager-get-secrets@v2 + uses: aws-actions/aws-secretsmanager-get-secrets@v3 with: secret-ids: | E2E,${{ secrets.E2E_SECRET_ARN }} @@ -60,7 +60,7 @@ jobs: - name: Generate GitHub App Token if: matrix.cdk-source == 'main' id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 70e9e54b3..0d932537e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -70,7 +70,7 @@ jobs: id: aws run: echo "account_id=$(aws sts get-caller-identity --query Account --output text)" >> "$GITHUB_OUTPUT" - name: Get API keys from Secrets Manager - uses: aws-actions/aws-secretsmanager-get-secrets@v2 + uses: aws-actions/aws-secretsmanager-get-secrets@v3 with: secret-ids: | E2E,${{ secrets.E2E_SECRET_ARN }} @@ -78,7 +78,7 @@ jobs: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/pr-tarball.yml b/.github/workflows/pr-tarball.yml index a217daacc..068461940 100644 --- a/.github/workflows/pr-tarball.yml +++ b/.github/workflows/pr-tarball.yml @@ -52,7 +52,7 @@ jobs: echo "name=$TARBALL_NAME" >> $GITHUB_OUTPUT - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/release-main-and-preview.yml b/.github/workflows/release-main-and-preview.yml index c726518f2..751a5167f 100644 --- a/.github/workflows/release-main-and-preview.yml +++ b/.github/workflows/release-main-and-preview.yml @@ -137,7 +137,7 @@ jobs: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} @@ -228,7 +228,7 @@ jobs: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} @@ -417,7 +417,7 @@ jobs: git push origin "v$VERSION" - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: v${{ needs.prepare-main.outputs.version }} name: AgentCore CLI v${{ needs.prepare-main.outputs.version }} @@ -473,7 +473,7 @@ jobs: git push origin "v$VERSION" - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: v${{ needs.prepare-preview.outputs.version }} name: AgentCore CLI v${{ needs.prepare-preview.outputs.version }} (Preview) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f14766188..a7bfb6416 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,7 +149,7 @@ jobs: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/slack-issue-notification.yml b/.github/workflows/slack-issue-notification.yml index 89d3be642..eca83b08b 100644 --- a/.github/workflows/slack-issue-notification.yml +++ b/.github/workflows/slack-issue-notification.yml @@ -26,7 +26,7 @@ jobs: ISSUE_AUTHOR: ${{ github.event.issue.user.login }} ISSUE_BODY: ${{ github.event.issue.body }} LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }} - uses: slackapi/slack-github-action@v2.1.1 + uses: slackapi/slack-github-action@v3.0.3 with: webhook: ${{ secrets.SLACK_WEBHOOK_URL }} webhook-type: webhook-trigger diff --git a/.github/workflows/slack-open-prs-notification.yml b/.github/workflows/slack-open-prs-notification.yml index 68dd1df49..be684d4b3 100644 --- a/.github/workflows/slack-open-prs-notification.yml +++ b/.github/workflows/slack-open-prs-notification.yml @@ -40,7 +40,7 @@ jobs: ); - name: Send open PRs summary to Slack - uses: slackapi/slack-github-action@v2.1.1 + uses: slackapi/slack-github-action@v3.0.3 with: webhook: ${{ secrets.SLACK_OPEN_PRS_WEBHOOK_URL }} webhook-type: webhook-trigger diff --git a/.github/workflows/strands-command.yml b/.github/workflows/strands-command.yml index dcf768206..73c848d65 100644 --- a/.github/workflows/strands-command.yml +++ b/.github/workflows/strands-command.yml @@ -96,7 +96,7 @@ jobs: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml index ef35927d9..326649d22 100644 --- a/.github/workflows/sync-from-public.yml +++ b/.github/workflows/sync-from-public.yml @@ -15,12 +15,12 @@ jobs: steps: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} @@ -115,12 +115,12 @@ jobs: steps: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/sync-preview.yml b/.github/workflows/sync-preview.yml index b4f0fa1b0..14f0add13 100644 --- a/.github/workflows/sync-preview.yml +++ b/.github/workflows/sync-preview.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} From 802c4c103138829ee0c2f8acfaac99a96adaf406 Mon Sep 17 00:00:00 2001 From: Trirmadura J Ariyawansa Date: Fri, 29 May 2026 12:43:30 -0400 Subject: [PATCH 18/27] fix: use branch prefix to detect existing sync conflict PRs (#204) The fuzzy text search (`--search "Merge public/main"`) was matching unrelated open PRs, causing the sync to skip creating conflict PRs. Switch to branch prefix matching (sync-conflict-main-* / sync-conflict-preview-*) which is exact and consistent with how sync-preview.yml detects its own PRs. Co-authored-by: jariy17 --- .github/workflows/sync-from-public.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-from-public.yml b/.github/workflows/sync-from-public.yml index 326649d22..70427ff97 100644 --- a/.github/workflows/sync-from-public.yml +++ b/.github/workflows/sync-from-public.yml @@ -60,7 +60,8 @@ jobs: git merge --abort # Check if a sync PR already exists - existing_pr=$(gh pr list --base "main" --search "Merge public/main" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + existing_pr=$(gh pr list --base "main" --state open --json number,headRefName \ + --jq '[.[] | select(.headRefName | startswith("sync-conflict-main-"))][0].number' 2>/dev/null || echo "") if [ -n "$existing_pr" ]; then echo "ℹ️ PR #$existing_pr already exists, skipping" @@ -160,7 +161,8 @@ jobs: git merge --abort # Check if a sync PR already exists - existing_pr=$(gh pr list --base "preview" --search "Merge public/preview" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + existing_pr=$(gh pr list --base "preview" --state open --json number,headRefName \ + --jq '[.[] | select(.headRefName | startswith("sync-conflict-preview-"))][0].number' 2>/dev/null || echo "") if [ -n "$existing_pr" ]; then echo "ℹ️ PR #$existing_pr already exists, skipping" From 08a74345a153352c89db97334bb85080cb362041 Mon Sep 17 00:00:00 2001 From: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:29:28 -0400 Subject: [PATCH 19/27] fix: delete dead file (#209) --- .../__tests__/error-classification.test.ts | 63 ----------------- src/cli/telemetry/error-classification.ts | 67 ------------------- 2 files changed, 130 deletions(-) delete mode 100644 src/cli/telemetry/__tests__/error-classification.test.ts delete mode 100644 src/cli/telemetry/error-classification.ts diff --git a/src/cli/telemetry/__tests__/error-classification.test.ts b/src/cli/telemetry/__tests__/error-classification.test.ts deleted file mode 100644 index 0640e6ce0..000000000 --- a/src/cli/telemetry/__tests__/error-classification.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { classifyError, isUserError } from '../error-classification'; -import { describe, expect, it } from 'vitest'; - -function errorWithName(name: string): Error { - const err = new Error('test'); - err.name = name; - return err; -} - -describe('classifyError', () => { - it.each([ - ['ConfigValidationError', 'ConfigError'], - ['ConfigNotFoundError', 'ConfigError'], - ['ConfigReadError', 'ConfigError'], - ['ConfigWriteError', 'ConfigError'], - ['ConfigParseError', 'ConfigError'], - ['AwsCredentialsError', 'CredentialsError'], - ['AccessDeniedException', 'CredentialsError'], - ['ExpiredToken', 'CredentialsError'], - ['PackagingError', 'PackagingError'], - ['MissingDependencyError', 'PackagingError'], - ['ArtifactSizeError', 'PackagingError'], - ['NoProjectError', 'ProjectError'], - ['AgentAlreadyExistsError', 'ProjectError'], - ['ResourceNotFoundException', 'ServiceError'], - ['ValidationException', 'ServiceError'], - ['ConflictException', 'ServiceError'], - ['ConnectionError', 'ConnectionError'], - ['ServerError', 'ConnectionError'], - ] as const)('%s → %s', (errorName, expected) => { - expect(classifyError(errorWithName(errorName))).toBe(expected); - }); - - it('returns UnknownError for unrecognized errors', () => { - expect(classifyError(new Error('something'))).toBe('UnknownError'); - }); - - it('returns UnknownError for non-Error values', () => { - expect(classifyError('string')).toBe('UnknownError'); - expect(classifyError(null)).toBe('UnknownError'); - expect(classifyError(undefined)).toBe('UnknownError'); - }); - - it('uses err.name when constructor.name is Error (SDK pattern)', () => { - // AWS SDK errors often: new Error(); err.name = 'ValidationException' - expect(classifyError(errorWithName('ValidationException'))).toBe('ServiceError'); - }); -}); - -describe('isUserError', () => { - it('returns true for user-fixable categories', () => { - expect(isUserError(errorWithName('ConfigValidationError'))).toBe(true); - expect(isUserError(errorWithName('AwsCredentialsError'))).toBe(true); - expect(isUserError(errorWithName('NoProjectError'))).toBe(true); - }); - - it('returns false for system categories', () => { - expect(isUserError(errorWithName('PackagingError'))).toBe(false); - expect(isUserError(errorWithName('ResourceNotFoundException'))).toBe(false); - expect(isUserError(errorWithName('ConnectionError'))).toBe(false); - expect(isUserError(new Error('unknown'))).toBe(false); - }); -}); diff --git a/src/cli/telemetry/error-classification.ts b/src/cli/telemetry/error-classification.ts deleted file mode 100644 index 1f5627699..000000000 --- a/src/cli/telemetry/error-classification.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { type ErrorCategory } from './schemas/common-shapes.js'; -import type { z } from 'zod'; - -type ErrorCategoryValue = z.infer; - -const CONFIG_ERRORS = new Set([ - 'ConfigValidationError', - 'ConfigNotFoundError', - 'ConfigReadError', - 'ConfigWriteError', - 'ConfigParseError', - 'ValidationError', -]); -const PACKAGING_ERRORS = new Set([ - 'PackagingError', - 'MissingDependencyError', - 'MissingProjectFileError', - 'UnsupportedLanguageError', - 'ArtifactSizeError', - 'DependencyCheckError', -]); -const CREDENTIAL_ERRORS = new Set([ - 'AwsCredentialsError', - 'AccessDeniedException', - 'AccessDenied', - 'AccessDeniedError', - 'ExpiredToken', - 'ExpiredTokenException', - 'TokenRefreshRequired', - 'CredentialsExpired', - 'InvalidIdentityToken', - 'UnauthorizedAccess', - 'InvalidClientTokenId', -]); -const PROJECT_ERRORS = new Set(['NoProjectError', 'AgentAlreadyExistsError']); -const CONNECTION_ERRORS = new Set(['ConnectionError', 'ServerError']); -const SERVICE_ERRORS = new Set([ - 'ResourceNotFoundException', - 'ValidationException', - 'ConflictException', - 'ResourceAlreadyExistsException', - 'ResourceNotFoundError', - 'ConflictError', -]); - -const USER_CATEGORIES = new Set(['ConfigError', 'CredentialsError', 'ProjectError']); - -export function classifyError(err: unknown): ErrorCategoryValue { - if (!(err instanceof Error)) return 'UnknownError'; - const name = - err.constructor.name === 'Error' - ? 'name' in err && typeof err.name === 'string' - ? err.name - : 'Error' - : err.constructor.name; - if (CONFIG_ERRORS.has(name)) return 'ConfigError'; - if (CREDENTIAL_ERRORS.has(name)) return 'CredentialsError'; - if (PACKAGING_ERRORS.has(name)) return 'PackagingError'; - if (PROJECT_ERRORS.has(name)) return 'ProjectError'; - if (SERVICE_ERRORS.has(name)) return 'ServiceError'; - if (CONNECTION_ERRORS.has(name)) return 'ConnectionError'; - return 'UnknownError'; -} - -export function isUserError(err: unknown): boolean { - return USER_CATEGORIES.has(classifyError(err)); -} From fe63a7dd5df6a1e456ff08be0b7c4725f27c5f96 Mon Sep 17 00:00:00 2001 From: "agentcore-devx-automation[bot]" <282717993+agentcore-devx-automation[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:32:19 -0400 Subject: [PATCH 20/27] =?UTF-8?q?=F0=9F=94=80=20[Sync=20Conflict]=20Merge?= =?UTF-8?q?=20public/main=20=E2=86=92=20main=20(#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: simplify release-main-and-preview workflow (#1415) Rename workflow to "Release Main and Preview" to distinguish it from the single-release workflow in the Actions UI. Remove redundant test-main and test-preview jobs since the release PR already triggers build-and-test CI. Confidence: high Scope-risk: narrow * Release v0.16.0 + preview v1.0.0-preview.10 (#1417) * chore: bump main to 0.16.0, preview to 1.0.0-preview.10 * Update CHANGELOG for version 0.16.0 Document notable changes for version 0.16.0, including new features, fixes, and updates. --------- Co-authored-by: github-actions[bot] Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> * fix: restore --skills invoke override for harness (preview regression) (#1418) The --skills flag was present on the preview branch but was accidentally dropped during the preview→main feature flag consolidation. The type field existed in InvokeOptions but was never wired to a CLI flag or passed through buildHarnessBaseOpts. Restores: - --skills CLI flag (preview-gated) - skills handling in buildHarnessBaseOpts to send to InvokeHarness API * fix(telemetry): track preflight error for telemetry (#1403) * fix(telemetry): track preflight error for telemetry * feat: model userCancellationError in telemetry * chore: remove sync-preview job from sync-from-public workflow (#1416) * ci: disable telemetry in e2e and integ test workflows (#1421) * ci: disable telemetry in e2e and integ test workflows * fix: avoid test reliance on env var * fix: remove test that is no longer testable * feat: wire telemetry for validate command (#1423) * refactor(telemetry): rename AgentType to AgentSource to remove ambiguity (#1422) --------- Co-authored-by: Jesse Turner <57651174+jesseturner21@users.noreply.github.com> Co-authored-by: agentcore-devx-automation[bot] <282717993+agentcore-devx-automation[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Gitika <53349492+notgitika@users.noreply.github.com> Co-authored-by: Hweinstock <42325418+Hweinstock@users.noreply.github.com> --- CHANGELOG.md | 19 +++++ integ-tests/telemetry.test.ts | 32 -------- integ-tests/validate.test.ts | 19 +++-- package.json | 2 +- preview-version.json | 2 +- schemas/agentcore.schema.v1.json | 21 +++++ src/cli/commands/create/command.tsx | 4 +- src/cli/commands/invoke/action.ts | 1 + src/cli/commands/invoke/command.tsx | 3 + .../telemetry/__tests__/telemetry.test.ts | 5 +- src/cli/commands/validate/command.tsx | 4 +- src/cli/primitives/AgentPrimitive.tsx | 4 +- src/cli/telemetry/__tests__/client.test.ts | 4 +- .../schemas/__tests__/command-run.test.ts | 8 +- src/cli/telemetry/schemas/command-run.ts | 6 +- src/cli/telemetry/schemas/common-shapes.ts | 5 +- src/cli/tui/hooks/useCdkPreflight.ts | 81 ++++++++----------- src/cli/tui/screens/agent/useAddAgent.ts | 4 +- src/cli/tui/screens/create/useCreateFlow.ts | 4 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 32 +++++++- src/lib/errors/types.ts | 9 +++ 21 files changed, 160 insertions(+), 109 deletions(-) delete mode 100644 integ-tests/telemetry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a372b3f81..8b0726028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## [0.16.0] - 2026-05-28 +* feat: instrument telemetry for status command by @Hweinstock in https://github.com/aws/agentcore-cli/pull/1317 +* fix(telemetry): emit dev command telemetry before blocking by @Hweinstock in https://github.com/aws/agentcore-cli/pull/1375 +* feat: compile-time feature flag for preview/GA consolidation by @jesseturner21 in https://github.com/aws/agentcore-cli/pull/1341 +* fix(ci): upload both GA and preview tarballs in prerelease workflow by @jesseturner21 in https://github.com/aws/agentcore-cli/pull/1386 +* feat(scripts): extend bundle to support injectable output and version suffix by @Hweinstock in https://github.com/aws/agentcore-cli/pull/1389 +* fix(ci): use cache for eslint/prettier/typecheck to speed up dev cycle by @Hweinstock in https://github.com/aws/agentcore-cli/pull/1390 +* fix(dataset): tui back-navigation bugs + kmsKeyArn support by @jariy17 in https://github.com/aws/agentcore-cli/pull/1379 +* fix: create global entrypoint for tui by @Hweinstock in https://github.com/aws/agentcore-cli/pull/1365 +* fix: centralize ANSI codes and disable colors in non-TTY output by @Hweinstock in https://github.com/aws/agentcore-cli/pull/1399 +* docs: Link to agentcore-samples repository in README by @notgitika in https://github.com/aws/agentcore-cli/pull/1400 +* fix: allow skipping optional KMS key ARN in evaluator wizard by @notgitika in https://github.com/aws/agentcore-cli/pull/1402 +* fix(status): show harnesses in agentcore status output by @jesseturner21 in https://github.com/aws/agentcore-cli/pull/1396 +* fix(deploy): add harness teardown to TUI deploy flow by @jesseturner21 in https://github.com/aws/agentcore-cli/pull/1394 +* feat: check for spans in agent log group by @avi-alpert in https://github.com/aws/agentcore-cli/pull/1404 +* fix(dev): skip redundant deploy when TUI deploy already succeeded by @jesseturner21 in https://github.com/aws/agentcore-cli/pull/1395 +* fix(harness): resolve memorySpec by deployed ARN for arn-only memory refs by @jesseturner21 in https://github.com/aws/agentcore-cli/pull/1407 +* chore: simplify release-main-and-preview workflow by @jesseturner21 in https://github.com/aws/agentcore-cli/pull/1415 + ## [0.15.0] - 2026-05-22 ## [0.14.2] - 2026-05-21 diff --git a/integ-tests/telemetry.test.ts b/integ-tests/telemetry.test.ts deleted file mode 100644 index 5347af0c8..000000000 --- a/integ-tests/telemetry.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { spawnAndCollect } from '../src/test-utils/cli-runner.js'; -import { mkdtempSync } from 'node:fs'; -import { rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterAll, describe, expect, it } from 'vitest'; - -const testConfigDir = mkdtempSync(join(tmpdir(), 'agentcore-integ-')); -const cliPath = join(__dirname, '..', 'dist', 'cli', 'index.mjs'); - -function run(args: string[]) { - return spawnAndCollect('node', [cliPath, ...args], tmpdir(), { - AGENTCORE_SKIP_INSTALL: '1', - AGENTCORE_CONFIG_DIR: testConfigDir, - }); -} - -describe('telemetry e2e', () => { - afterAll(() => rm(testConfigDir, { recursive: true, force: true })); - - it('disable → status shows Disabled, enable → status shows Enabled', async () => { - await run(['telemetry', 'disable']); - let status = await run(['telemetry', 'status']); - expect(status.stdout).toContain('Disabled'); - expect(status.stdout).toContain('global config'); - - await run(['telemetry', 'enable']); - status = await run(['telemetry', 'status']); - expect(status.stdout).toContain('Enabled'); - expect(status.stdout).toContain('global config'); - }); -}); diff --git a/integ-tests/validate.test.ts b/integ-tests/validate.test.ts index 56ad03f16..b5453a55d 100644 --- a/integ-tests/validate.test.ts +++ b/integ-tests/validate.test.ts @@ -1,14 +1,15 @@ /* eslint-disable security/detect-non-literal-fs-filename */ -import { createTestProject, runCLI } from '../src/test-utils/index.js'; +import { createTelemetryHelper, createTestProject, runCLI } from '../src/test-utils/index.js'; import type { TestProject } from '../src/test-utils/index.js'; import { randomUUID } from 'node:crypto'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; describe('integration: validate command', () => { let project: TestProject; + const telemetry = createTelemetryHelper(); beforeAll(async () => { project = await createTestProject({ @@ -19,16 +20,22 @@ describe('integration: validate command', () => { }); }); + afterEach(() => { + telemetry.clearEntries(); + }); + afterAll(async () => { + telemetry.destroy(); await project.cleanup(); }); it('validates a valid project successfully', async () => { - const result = await runCLI(['validate'], project.projectPath); + const result = await runCLI(['validate'], project.projectPath, { env: telemetry.env }); expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); // validate outputs "Valid" on success (Ink text render) expect(result.stdout.toLowerCase()).toContain('valid'); + telemetry.assertMetricEmitted({ command: 'validate', exit_reason: 'success' }); }); it('reports error for corrupted agentcore.json', async () => { @@ -39,12 +46,13 @@ describe('integration: validate command', () => { try { await writeFile(configPath, '{invalid json!!!', 'utf-8'); - const result = await runCLI(['validate'], project.projectPath); + const result = await runCLI(['validate'], project.projectPath, { env: telemetry.env }); expect(result.exitCode).toBe(1); // Error message should appear in stdout (Ink render) or stderr const output = result.stdout + result.stderr; expect(output.length, 'Should produce error output').toBeGreaterThan(0); + telemetry.assertMetricEmitted({ command: 'validate', exit_reason: 'failure' }); } finally { // Restore original config so other tests aren't affected await writeFile(configPath, originalContent, 'utf-8'); @@ -56,12 +64,13 @@ describe('integration: validate command', () => { await mkdir(emptyDir, { recursive: true }); try { - const result = await runCLI(['validate'], emptyDir); + const result = await runCLI(['validate'], emptyDir, { env: telemetry.env }); expect(result.exitCode).toBe(1); // Error message should appear somewhere in output const output = result.stdout + result.stderr; expect(output.length, 'Should produce error output').toBeGreaterThan(0); + telemetry.assertMetricEmitted({ command: 'validate', exit_reason: 'failure' }); } finally { await rm(emptyDir, { recursive: true, force: true }); } diff --git a/package.json b/package.json index fd7e31f4b..bd0ebfebc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aws/agentcore", - "version": "0.15.0", + "version": "0.16.0", "description": "CLI for Amazon Bedrock AgentCore", "license": "Apache-2.0", "repository": { diff --git a/preview-version.json b/preview-version.json index d676b184a..cc8943303 100644 --- a/preview-version.json +++ b/preview-version.json @@ -1,3 +1,3 @@ { - "version": "1.0.0-preview.9" + "version": "1.0.0-preview.10" } diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index 1a888205b..8780a3ded 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -2184,6 +2184,27 @@ "additionalProperties": false } }, + "harnesses": { + "default": [], + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 48, + "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,47}$" + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "required": ["name", "path"], + "additionalProperties": false + } + }, "datasets": { "type": "array", "items": { diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 646c3e572..78e566779 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -17,7 +17,7 @@ import { AgentFramework, AgentLanguage, AgentProtocol, - AgentType, + AgentSource, MemoryType, ModelProvider as ModelProviderEnum, NetworkMode as NetworkModeEnum, @@ -251,7 +251,7 @@ async function handleCreateCLI(options: CreateOptions): Promise { memory_type: standardize(MemoryType, options.memory ?? 'none'), agent_protocol: standardize(AgentProtocol, options.protocol ?? 'http'), build_type: standardize(TelemetryBuildType, options.build ?? 'codezip'), - agent_type: standardize(AgentType, options.type ?? 'create'), + agent_source: standardize(AgentSource, options.type ?? 'create'), network_mode: standardize(NetworkModeEnum, options.networkMode ?? 'public'), has_agent: options.agent !== false, }; diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index 1eca4d9f3..5d445a7e1 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -605,6 +605,7 @@ export function buildHarnessBaseOpts( if (options.maxIterations != null) baseOpts.maxIterations = options.maxIterations; if (options.maxTokens != null) baseOpts.maxTokens = options.maxTokens; if (options.harnessTimeout != null) baseOpts.timeoutSeconds = options.harnessTimeout; + if (options.skills) baseOpts.skills = options.skills.split(',').map(p => ({ path: p.trim() })); if (options.systemPrompt) baseOpts.systemPrompt = [{ text: options.systemPrompt }]; if (options.allowedTools) baseOpts.allowedTools = options.allowedTools.split(',').map(t => t.trim()); if (options.actorId) baseOpts.actorId = options.actorId; diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 0a847eab2..0ef293d9a 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -174,6 +174,7 @@ export const registerInvoke = (program: Command) => { 'Override timeout seconds (harness only) [non-interactive] [preview]', parseInt ) + .option('--skills ', 'Skills to use, comma-separated paths (harness only) [non-interactive] [preview]') .option('--system-prompt ', 'Override system prompt (harness only) [non-interactive] [preview]') .option( '--allowed-tools ', @@ -211,6 +212,7 @@ export const registerInvoke = (program: Command) => { maxIterations?: number; maxTokens?: number; harnessTimeout?: number; + skills?: string; systemPrompt?: string; allowedTools?: string; actorId?: string; @@ -306,6 +308,7 @@ export const registerInvoke = (program: Command) => { maxIterations: cliOptions.maxIterations, maxTokens: cliOptions.maxTokens, harnessTimeout: cliOptions.harnessTimeout, + skills: cliOptions.skills, systemPrompt: cliOptions.systemPrompt, allowedTools: cliOptions.allowedTools, actorId: cliOptions.actorId, diff --git a/src/cli/commands/telemetry/__tests__/telemetry.test.ts b/src/cli/commands/telemetry/__tests__/telemetry.test.ts index efdfd2f23..2c9afbfe4 100644 --- a/src/cli/commands/telemetry/__tests__/telemetry.test.ts +++ b/src/cli/commands/telemetry/__tests__/telemetry.test.ts @@ -9,7 +9,10 @@ const tmp = createTempConfig('actions'); describe('telemetry actions', () => { const originalEnv = process.env; - beforeEach(() => tmp.setup()); + beforeEach(async () => { + await tmp.setup(); + delete process.env.AGENTCORE_TELEMETRY_DISABLED; + }); afterEach(() => { process.env = originalEnv; diff --git a/src/cli/commands/validate/command.tsx b/src/cli/commands/validate/command.tsx index 7f2fb2bff..4b13a4869 100644 --- a/src/cli/commands/validate/command.tsx +++ b/src/cli/commands/validate/command.tsx @@ -1,3 +1,4 @@ +import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { handleValidate } from './action'; import type { Command } from '@commander-js/extra-typings'; @@ -9,8 +10,7 @@ export const registerValidate = (program: Command) => { .option('-d, --directory ', 'Project directory containing agentcore config') .description(COMMAND_DESCRIPTIONS.validate) .action(async options => { - const result = await handleValidate(options); - + const result = await withCommandRunTelemetry('validate', {}, async () => handleValidate(options)); if (result.success) { render(Valid); process.exit(0); diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index d6927f85e..f6b987428 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -52,7 +52,7 @@ import { AgentFramework, AgentLanguage, AgentProtocol, - AgentType, + AgentSource, AuthorizerType, MemoryType, ModelProvider as ModelProviderEnum, @@ -368,7 +368,7 @@ export class AgentPrimitive extends BasePrimitive { memory_type: 'shortterm', agent_protocol: 'mcp', build_type: 'codezip', - agent_type: 'create', + agent_source: 'create', network_mode: 'public', has_agent: true, }, @@ -118,7 +118,7 @@ describe('withCommandRunTelemetry', () => { memory_type: 'none', agent_protocol: 'http', build_type: 'codezip', - agent_type: 'create', + agent_source: 'create', network_mode: 'public', has_agent: true, }, diff --git a/src/cli/telemetry/schemas/__tests__/command-run.test.ts b/src/cli/telemetry/schemas/__tests__/command-run.test.ts index 561899d7e..65b42c74b 100644 --- a/src/cli/telemetry/schemas/__tests__/command-run.test.ts +++ b/src/cli/telemetry/schemas/__tests__/command-run.test.ts @@ -94,7 +94,7 @@ describe('COMMAND_SCHEMAS', () => { memory_type: 'shortterm', agent_protocol: 'mcp', build_type: 'codezip', - agent_type: 'create', + agent_source: 'create', network_mode: 'public', has_agent: true, }; @@ -110,7 +110,7 @@ describe('COMMAND_SCHEMAS', () => { memory_type: 'shortterm', agent_protocol: 'mcp', build_type: 'codezip', - agent_type: 'create', + agent_source: 'create', network_mode: 'public', has_agent: true, }) @@ -246,7 +246,7 @@ describe('resilientParse', () => { memory_type: 'shortterm', agent_protocol: 'mcp', build_type: 'codezip', - agent_type: 'create', + agent_source: 'create', network_mode: 'public', has_agent: true, }; @@ -261,7 +261,7 @@ describe('resilientParse', () => { memory_type: 'shortterm', agent_protocol: 'mcp', build_type: 'codezip', - agent_type: 'create', + agent_source: 'create', network_mode: 'public', has_agent: true, }; diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index 631fccb11..4bb25bd3a 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -2,7 +2,7 @@ import { AgentFramework, AgentLanguage, AgentProtocol, - AgentType, + AgentSource, AttachMode, AuthType, AuthorizerType, @@ -39,7 +39,7 @@ const CreateAttrs = safeSchema({ memory_type: MemoryType, agent_protocol: AgentProtocol, build_type: BuildType, - agent_type: AgentType, + agent_source: AgentSource, network_mode: NetworkMode, has_agent: z.boolean(), }); @@ -48,7 +48,7 @@ const AddAgentAttrs = safeSchema({ agent_language: AgentLanguage, agent_framework: AgentFramework, model_provider: ModelProvider, - agent_type: AgentType, + agent_source: AgentSource, build_type: BuildType, agent_protocol: AgentProtocol, network_mode: NetworkMode, diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index 552abfd5d..e16230098 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -46,7 +46,7 @@ export const Count = z.number().int().nonnegative(); export const DevAction = z.enum(['server', 'invoke', 'exec']); export const UiMode = z.enum(['browser', 'terminal']); -export const AgentType = z.enum(['create', 'byo', 'import']); +export const AgentSource = z.enum(['create', 'byo', 'import']); export const AttachMode = z.enum(['log_only', 'enforce']); export const AuthType = z.enum(['sigv4', 'bearer_token']); export const AuthorizerType = z.enum(['aws_iam', 'custom_jwt', 'none']); @@ -128,6 +128,7 @@ export const ErrorName = z.enum([ 'ServerError', 'TimeoutError', 'UnsupportedLanguageError', + 'UserCancellationError', 'ValidationError', 'UnknownError', ]); @@ -153,7 +154,7 @@ export type DeployMode = z.infer; */ export const ATTRIBUTES = { dev_action: DevAction, - agent_type: AgentType, + agent_source: AgentSource, attach_gateway_count: Count, attach_mode: AttachMode, auth_type: AuthType, diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index 4602c1c14..3ab7256ec 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -1,5 +1,5 @@ -import { ConfigIO, SecureCredentials } from '../../../lib'; -import { AwsCredentialsError } from '../../../lib/errors/types'; +import { ConfigIO, SecureCredentials, toError } from '../../../lib'; +import { AwsCredentialsError, UserCancellationError } from '../../../lib/errors/types'; import type { DeployedState } from '../../../schema'; import { applyTargetRegionToEnv } from '../../aws'; import { validateAwsCredentials } from '../../aws/account'; @@ -66,6 +66,8 @@ export interface PreflightResult { hasTokenExpiredError: boolean; /** True if preflight failed due to missing AWS credentials (not configured) */ hasCredentialsError: boolean; + /** The error that caused preflight to fail, if any */ + lastError?: Error; /** Missing credentials that need to be provided */ missingCredentials: MissingCredential[]; /** KMS key ARN used for identity token vault encryption */ @@ -134,6 +136,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { Record >({}); const [teardownConfirmed, setTeardownConfirmed] = useState(false); + const lastErrorRef = useRef(undefined); // Guard against concurrent runs (React StrictMode, re-renders, etc.) const isRunningRef = useRef(false); @@ -157,6 +160,12 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { setSteps(BASE_PREFLIGHT_STEPS.map(s => ({ ...s, status: 'pending' as const }))); }; + const failPreflight = (err: unknown) => { + lastErrorRef.current = toError(err); + setPhase('error'); + isRunningRef.current = false; + }; + // Dispose wrapper and clear ref const disposeWrapper = useCallback(async () => { if (wrapperRef.current) { @@ -231,8 +240,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { }, []); const cancelTeardown = useCallback(() => { - setPhase('error'); - isRunningRef.current = false; + failPreflight(new UserCancellationError()); restoreRegionEnv(); }, [restoreRegionEnv]); @@ -269,6 +277,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { if (phase !== 'running') return; if (isRunningRef.current) return; isRunningRef.current = true; + lastErrorRef.current = undefined; const handleUnhandledRejection = (reason: unknown) => { const error = formatError(reason); @@ -287,8 +296,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } return prev; }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(reason); }; process.on('unhandledRejection', handleUnhandledRejection); @@ -329,8 +337,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { userMessage = getErrorMessage(err); } updateStep(STEP_VALIDATE, { status: 'error', error: userMessage }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -354,8 +361,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const userMessage = isInteractive && err instanceof AwsCredentialsError ? err.shortMessage : getErrorMessage(err); updateStep(STEP_VALIDATE, { status: 'error', error: userMessage }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } } @@ -369,8 +375,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = depsResult.errors.join('\n'); logger.endStep('error', errorMsg); updateStep(STEP_DEPS, { status: 'error', error: errorMsg }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error(errorMsg)); return; } // Log version info @@ -386,8 +391,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = formatError(err); logger.endStep('error', errorMsg); updateStep(STEP_DEPS, { status: 'error', error: logger.getFailureMessage('Check dependencies') }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -402,8 +406,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = formatError(err); logger.endStep('error', errorMsg); updateStep(STEP_BUILD, { status: 'error', error: logger.getFailureMessage('Build CDK project') }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -450,8 +453,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Synthesize CloudFormation'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -466,8 +468,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = stackStatus.message ?? `Stack ${stackStatus.blockingStack} is not in a deployable state`; logger.endStep('error', errorMsg); updateStepByLabel(LABEL_STACK_STATUS, { status: 'error', error: errorMsg }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error(errorMsg)); return; } logger.endStep('success'); @@ -482,8 +483,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Check stack status'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } } else { @@ -520,8 +520,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } return prev; }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); } }; @@ -566,8 +565,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Synthesize CloudFormation'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -582,8 +580,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = stackStatus.message ?? `Stack ${stackStatus.blockingStack} is not in a deployable state`; logger.endStep('error', errorMsg); updateStepByLabel(LABEL_STACK_STATUS, { status: 'error', error: errorMsg }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error(errorMsg)); return; } logger.endStep('success'); @@ -598,8 +595,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Check stack status'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } } else { @@ -647,8 +643,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } else if (hasOAuth) { updateStepByLabel(LABEL_OAUTH, { status: 'error', error: errorMsg }); } - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error(errorMsg)); return; } @@ -697,8 +692,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { if (identityResult.hasErrors) { logger.endStep('error', 'Some API key providers failed to set up'); updateStepByLabel(LABEL_API_KEY, { status: 'error', error: 'Some API key providers failed' }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error('Some API key providers failed to set up')); return; } @@ -741,8 +735,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { if (oauthResult.hasErrors) { logger.endStep('error', 'Some OAuth providers failed to set up'); updateStepByLabel(LABEL_OAUTH, { status: 'error', error: 'Some OAuth providers failed' }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error('Some OAuth providers failed to set up')); return; } @@ -807,8 +800,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Synthesize CloudFormation'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } @@ -822,8 +814,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const errorMsg = stackStatus.message ?? `Stack ${stackStatus.blockingStack} is not in a deployable state`; logger.endStep('error', errorMsg); updateStepByLabel(LABEL_STACK_STATUS, { status: 'error', error: errorMsg }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(new Error(errorMsg)); return; } logger.endStep('success'); @@ -838,8 +829,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { status: 'error', error: logger.getFailureMessage('Check stack status'), }); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); return; } } else { @@ -872,8 +862,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { : s ) ); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); } }; @@ -908,8 +897,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { : s ) ); - setPhase('error'); - isRunningRef.current = false; + failPreflight(err); } }; @@ -925,6 +913,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { switchableIoHost, hasTokenExpiredError, hasCredentialsError, + lastError: lastErrorRef.current, missingCredentials, identityKmsKeyArn, allCredentials, diff --git a/src/cli/tui/screens/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index 80b63af26..f72475e03 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -25,7 +25,7 @@ import { AgentFramework, AgentLanguage, AgentProtocol, - AgentType as AgentTypeEnum, + AgentSource, AuthorizerType as AuthorizerTypeEnum, BuildType, MemoryType as MemoryEnum, @@ -163,7 +163,7 @@ export function useAddAgent() { agent_language: standardize(AgentLanguage, config.language), agent_framework: standardize(AgentFramework, config.framework), model_provider: standardize(ModelProvider, config.modelProvider), - agent_type: standardize(AgentTypeEnum, config.agentType), + agent_source: standardize(AgentSource, config.agentType), build_type: standardize(BuildType, config.buildType), agent_protocol: standardize(AgentProtocol, config.protocol ?? 'HTTP'), network_mode: standardize(NetworkMode, config.networkMode ?? 'PUBLIC'), diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index 3c5386476..645bcc095 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -29,7 +29,7 @@ import { AgentFramework, AgentLanguage, AgentProtocol, - AgentType, + AgentSource, BuildType, MemoryType as MemoryEnum, ModelProvider, @@ -266,7 +266,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { memory_type: standardize(MemoryEnum, addAgentConfig?.memory ?? 'none'), agent_protocol: standardize(AgentProtocol, addAgentConfig?.protocol ?? 'HTTP'), build_type: standardize(BuildType, addAgentConfig?.buildType ?? 'CodeZip'), - agent_type: standardize(AgentType, addAgentConfig?.agentType ?? 'create'), + agent_source: standardize(AgentSource, addAgentConfig?.agentType ?? 'create'), network_mode: standardize(NetworkMode, addAgentConfig?.networkMode ?? 'PUBLIC'), has_agent: addAgentConfig !== null, }; diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 4b5e36480..fa6a0730f 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -646,8 +646,20 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // Start deploy when preflight completes OR when shouldStartDeploy is set useEffect(() => { if (diffMode) return; // Diff mode uses its own effect - const shouldStart = skipPreflight ? shouldStartDeploy : preflight.phase === 'complete'; + const preflightDone = preflight.phase === 'complete' || preflight.phase === 'error'; + const shouldStart = skipPreflight ? shouldStartDeploy : preflightDone; if (!shouldStart) return; + + // Preflight failed — emit telemetry and bail + if (preflight.phase === 'error') { + const error = preflight.lastError ?? new Error('Preflight failed'); + const attrs = context ? computeDeployAttrs(context.projectSpec, 'deploy') : { ...DEFAULT_DEPLOY_ATTRS }; + withCommandRunTelemetry('deploy', attrs, () => ({ success: false as const, error })).catch(() => { + /* telemetry is best-effort */ + }); + return; + } + if (deployStep.status !== 'pending') return; if (!cdkToolkitWrapper) return; @@ -851,6 +863,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState }; void withCommandRunTelemetry('deploy', attrs, run); + // eslint-disable-next-line react-hooks/exhaustive-deps -- preflight.lastError and context are read only on error path }, [ preflight.phase, cdkToolkitWrapper, @@ -870,8 +883,22 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // Start diff when preflight completes (diff mode only) useEffect(() => { if (!diffMode) return; - const shouldStart = skipPreflight ? shouldStartDeploy : preflight.phase === 'complete'; + const preflightDone = preflight.phase === 'complete' || preflight.phase === 'error'; + const shouldStart = skipPreflight ? shouldStartDeploy : preflightDone; if (!shouldStart) return; + + // Preflight failed — emit telemetry and bail + if (preflight.phase === 'error') { + const error = preflight.lastError ?? new Error('Preflight failed'); + const attrs = context + ? computeDeployAttrs(context.projectSpec, 'diff') + : { ...DEFAULT_DEPLOY_ATTRS, deploy_mode: 'diff' as const }; + withCommandRunTelemetry('deploy', attrs, () => ({ success: false as const, error })).catch(() => { + /* telemetry is best-effort */ + }); + return; + } + if (diffStep.status !== 'pending') return; if (!cdkToolkitWrapper) return; @@ -924,6 +951,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState }; void withCommandRunTelemetry('deploy', attrs, run); + // eslint-disable-next-line react-hooks/exhaustive-deps -- preflight.lastError and context are read only on error path }, [ diffMode, preflight.phase, diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index 16940a3d2..726312eba 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -290,3 +290,12 @@ export class PollExhaustedError extends BaseError { super(`Polling exhausted after ${maxAttempts} attempts`, { defaultSource: 'service', ...options }); } } + +/** + * Error indicating user cancellation interuption + */ +export class UserCancellationError extends BaseError { + constructor(options?: BaseErrorOptions) { + super(`User cancelled`, { defaultSource: 'user', ...options }); + } +} From c0357d7c5dbe6e2b580b185d8741c151505e8789 Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:55:55 -0400 Subject: [PATCH 21/27] feat: add CMK support for batch evaluation and recommendation APIs (#223) * feat: add CMK support for batch evaluation and recommendation APIs Thread `kmsKeyArn` through the CLI for both `run batch-evaluation` and `run recommendation` commands, enabling customers to encrypt evaluation results with their own KMS key. - CLI: adds `--kms-key ` flag to both commands - API layer: sends kmsKeyArn in StartBatchEvaluation request, maps it from GetBatchEvaluation response - TUI: adds optional KMS Key ARN step to batch eval and recommendation wizards (press Enter to skip) * fix: add KMS ARN validation and unit tests for batch eval API - Add customValidation for KMS key ARN on TextInput in both RunBatchEvalFlow and RecommendationScreen (uses existing isValidKmsKeyArn from schema) - Add comprehensive unit tests for agentcore-batch-evaluation API covering all endpoints and kmsKeyArn input/output - Add kmsKeyArn inclusion/omission tests to recommendation API tests Addresses PR review comments from jariy17. * test: add isValidKmsKeyArn validation tests Cover commercial, GovCloud, and China partition ARNs along with rejection cases (wrong service, alias, bad account ID, invalid UUID). --- .../agentcore-batch-evaluation.test.ts | 357 ++++++++++++++++++ .../agentcore-recommendation.test.ts | 75 ++++ src/cli/aws/agentcore-batch-evaluation.ts | 6 + src/cli/commands/run/command.tsx | 6 + .../operations/eval/run-batch-evaluation.ts | 3 + .../recommendation/run-recommendation.ts | 1 + src/cli/operations/recommendation/types.ts | 2 + .../recommendation/RecommendationFlow.tsx | 1 + .../recommendation/RecommendationScreen.tsx | 24 ++ src/cli/tui/screens/recommendation/types.ts | 4 + .../recommendation/useRecommendationWizard.ts | 11 + .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 38 +- .../primitives/__tests__/evaluator.test.ts | 43 +++ 13 files changed, 567 insertions(+), 4 deletions(-) create mode 100644 src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts diff --git a/src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts b/src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts new file mode 100644 index 000000000..262aad0e1 --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts @@ -0,0 +1,357 @@ +import { + deleteBatchEvaluation, + getBatchEvaluation, + listBatchEvaluations, + startBatchEvaluation, + stopBatchEvaluation, +} from '../agentcore-batch-evaluation.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +vi.mock('../account', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + sessionToken: 'TOKEN', + }), +})); + +vi.mock('@smithy/signature-v4', () => ({ + SignatureV4: class { + // eslint-disable-next-line @typescript-eslint/require-await + async sign(request: { headers: Record }) { + return { headers: { ...request.headers, Authorization: 'signed' } }; + } + }, +})); + +vi.mock('@aws-crypto/sha256-js', () => ({ + Sha256: class {}, +})); + +vi.mock('@aws-sdk/credential-provider-node', () => ({ + defaultProvider: vi.fn(), +})); + +function mockJsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +describe('agentcore-batch-evaluation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('startBatchEvaluation', () => { + it('sends POST to /evaluations/batch-evaluate with correct body', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:batch-evaluation/batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + const result = await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + }); + + expect(result.batchEvaluationId).toBe('batch-123'); + expect(result.name).toBe('MyBatchEval'); + expect(result.status).toBe('PENDING'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/evaluations/batch-evaluate'), + expect.objectContaining({ method: 'POST' }) + ); + + const fetchCall = mockFetch.mock.calls[0]!; + const body = JSON.parse(fetchCall[1].body); + expect(body.batchEvaluationName).toBe('MyBatchEval'); + expect(body.evaluators).toEqual([{ evaluatorId: 'eval-1' }]); + }); + + it('includes kmsKeyArn when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + kmsKeyArn: 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012', + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.kmsKeyArn).toBe('arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'); + }); + + it('omits kmsKeyArn when not provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.kmsKeyArn).toBeUndefined(); + }); + + it('includes description when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + description: 'Test evaluation run', + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.description).toBe('Test evaluation run'); + }); + + it('includes clientToken when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + clientToken: 'token-abc', + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.clientToken).toBe('token-abc'); + }); + + it('includes evaluationMetadata when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + evaluationMetadata: { + sessionMetadata: [{ sessionId: 'sess-1', metadata: { referenceAnswer: 'answer' } }], + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.evaluationMetadata.sessionMetadata).toEqual([ + { sessionId: 'sess-1', metadata: { referenceAnswer: 'answer' } }, + ]); + }); + + it('throws on non-ok response', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + text: () => Promise.resolve('Bad Request'), + }); + + await expect( + startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [], + dataSourceConfig: { + cloudWatchLogs: { serviceNames: [], logGroupNames: [] }, + }, + }) + ).rejects.toThrow('BatchEvaluation API error (400)'); + }); + }); + + describe('getBatchEvaluation', () => { + it('sends GET to /evaluations/batch-evaluate/{id}', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'COMPLETED', + kmsKeyArn: 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012', + }) + ); + + const result = await getBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); + + expect(result.batchEvaluationId).toBe('batch-123'); + expect(result.name).toBe('MyBatchEval'); + expect(result.status).toBe('COMPLETED'); + expect(result.kmsKeyArn).toBe('arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/evaluations/batch-evaluate/batch-123'), + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('returns undefined kmsKeyArn when not present in response', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'COMPLETED', + }) + ); + + const result = await getBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); + expect(result.kmsKeyArn).toBeUndefined(); + }); + }); + + describe('listBatchEvaluations', () => { + it('sends GET to /evaluations/batch-evaluate', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluations: [ + { batchEvaluationId: 'b1', name: 'Eval1', status: 'COMPLETED' }, + { batchEvaluationId: 'b2', name: 'Eval2', status: 'PENDING' }, + ], + }) + ); + + const result = await listBatchEvaluations({ region: 'us-west-2' }); + + expect(result.batchEvaluations).toHaveLength(2); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/evaluations/batch-evaluate'), + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('includes maxResults and nextToken query params', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ batchEvaluations: [], nextToken: undefined })); + + await listBatchEvaluations({ region: 'us-west-2', maxResults: 5, nextToken: 'page2' }); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain('maxResults=5'); + expect(url).toContain('nextToken=page2'); + }); + }); + + describe('stopBatchEvaluation', () => { + it('sends POST to /evaluations/batch-evaluate/{id}/stop', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + status: 'STOPPING', + }) + ); + + const result = await stopBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); + + expect(result.batchEvaluationId).toBe('batch-123'); + expect(result.status).toBe('STOPPING'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/evaluations/batch-evaluate/batch-123/stop'), + expect.objectContaining({ method: 'POST' }) + ); + }); + }); + + describe('deleteBatchEvaluation', () => { + it('sends DELETE to /evaluations/batch-evaluate/{id}', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 204, + headers: new Map(), + json: () => Promise.resolve({}), + text: () => Promise.resolve(''), + }); + + await deleteBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/evaluations/batch-evaluate/batch-123'), + expect.objectContaining({ method: 'DELETE' }) + ); + }); + }); +}); diff --git a/src/cli/aws/__tests__/agentcore-recommendation.test.ts b/src/cli/aws/__tests__/agentcore-recommendation.test.ts index 1b330cf30..993c28378 100644 --- a/src/cli/aws/__tests__/agentcore-recommendation.test.ts +++ b/src/cli/aws/__tests__/agentcore-recommendation.test.ts @@ -171,6 +171,81 @@ describe('agentcore-recommendation', () => { expect(body.description).toBe('Test description'); }); + it('includes kmsKeyArn when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + recommendationId: 'r1', + recommendationArn: 'arn:1', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }) + ); + + await startRecommendation({ + region: 'us-west-2', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + recommendationConfig: { + systemPromptRecommendationConfig: { + systemPrompt: { text: '' }, + agentTraces: { + cloudwatchLogs: { + logGroupArns: [], + serviceNames: ['bedrock-agentcore'], + startTime: '2026-03-23T00:00:00.000Z', + endTime: '2026-03-30T00:00:00.000Z', + }, + }, + evaluationConfig: { + evaluators: [{ evaluatorArn: 'arn:aws:bedrock-agentcore:::evaluator/Builtin.Helpfulness' }], + }, + }, + }, + kmsKeyArn: 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012', + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.kmsKeyArn).toBe('arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'); + }); + + it('omits kmsKeyArn when not provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + recommendationId: 'r1', + recommendationArn: 'arn:1', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }) + ); + + await startRecommendation({ + region: 'us-west-2', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + recommendationConfig: { + systemPromptRecommendationConfig: { + systemPrompt: { text: '' }, + agentTraces: { + cloudwatchLogs: { + logGroupArns: [], + serviceNames: ['bedrock-agentcore'], + startTime: '2026-03-23T00:00:00.000Z', + endTime: '2026-03-30T00:00:00.000Z', + }, + }, + evaluationConfig: { + evaluators: [{ evaluatorArn: 'arn:aws:bedrock-agentcore:::evaluator/Builtin.Helpfulness' }], + }, + }, + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.kmsKeyArn).toBeUndefined(); + }); + it('throws on non-ok response', async () => { mockFetch.mockResolvedValue({ ok: false, diff --git a/src/cli/aws/agentcore-batch-evaluation.ts b/src/cli/aws/agentcore-batch-evaluation.ts index 9b0923753..986e238d3 100644 --- a/src/cli/aws/agentcore-batch-evaluation.ts +++ b/src/cli/aws/agentcore-batch-evaluation.ts @@ -99,6 +99,7 @@ export interface StartBatchEvaluationOptions { evaluationMetadata?: EvaluationMetadata; description?: string; clientToken?: string; + kmsKeyArn?: string; } export interface StartBatchEvaluationResult { @@ -159,6 +160,7 @@ export interface GetBatchEvaluationResult { evaluationResults?: EvaluationResults; errorDetails?: string[]; description?: string; + kmsKeyArn?: string; } export interface BatchEvaluationResultEntry { @@ -295,6 +297,9 @@ export async function startBatchEvaluation(options: StartBatchEvaluationOptions) if (options.clientToken) { body.clientToken = options.clientToken; } + if (options.kmsKeyArn) { + body.kmsKeyArn = options.kmsKeyArn; + } const { data } = await signedRequest({ region: options.region, @@ -337,6 +342,7 @@ export async function getBatchEvaluation(options: GetBatchEvaluationOptions): Pr evaluationResults: raw.evaluationResults as EvaluationResults | undefined, errorDetails: raw.errorDetails as string[] | undefined, description: raw.description as string | undefined, + kmsKeyArn: raw.kmsKeyArn as string | undefined, }; } diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index 091d95727..b8d9bf3d5 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -195,6 +195,7 @@ export const registerRun = (program: Command) => { ) .option('--dataset ', 'Dataset name — invoke agent with dataset scenarios before batch evaluation') .option('--dataset-version ', 'Dataset version to use (omit for local file, or N/DRAFT)') + .option('--kms-key ', 'KMS key ARN for encrypting batch evaluation results') .option('--json', 'Output as JSON') .action( async (cliOptions: { @@ -208,6 +209,7 @@ export const registerRun = (program: Command) => { endpoint?: string; dataset?: string; datasetVersion?: string; + kmsKey?: string; json?: boolean; }) => { requireProject(); @@ -242,6 +244,7 @@ export const registerRun = (program: Command) => { sessionMetadata, dataset: cliOptions.dataset, datasetVersion: cliOptions.datasetVersion, + kmsKeyArn: cliOptions.kmsKey, onProgress: cliOptions.json ? undefined : (_status, message) => { @@ -320,6 +323,7 @@ export const registerRun = (program: Command) => { .option('-s, --session-id ', 'Limit trace collection to specific session IDs') .option('-n, --run ', 'Run name prefix for the recommendation') .option('--region ', 'AWS region') + .option('--kms-key ', 'KMS key ARN for encrypting recommendation results') .option('--json', 'Output as JSON') .action( async (cliOptions: { @@ -338,6 +342,7 @@ export const registerRun = (program: Command) => { sessionId?: string[]; run?: string; region?: string; + kmsKey?: string; json?: boolean; }) => { requireProject(); @@ -421,6 +426,7 @@ export const registerRun = (program: Command) => { spansFile: cliOptions.spansFile, recommendationName: cliOptions.run, region: cliOptions.region, + kmsKeyArn: cliOptions.kmsKey, inputSource, traceSource, onProgress: cliOptions.json diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index 4b68456ed..accd3b711 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -54,6 +54,8 @@ export interface RunBatchEvaluationOptions { datasetVersion?: string; /** Runtime endpoint name (e.g. PROMPT_V1). Defaults to DEFAULT. */ endpoint?: string; + /** KMS key ARN for encrypting batch evaluation results */ + kmsKeyArn?: string; } export interface BatchEvaluationResult { @@ -279,6 +281,7 @@ export async function runBatchEvaluationCommand( }, }, ...(allSessionMetadata.length > 0 ? { evaluationMetadata: { sessionMetadata: allSessionMetadata } } : {}), + ...(options.kmsKeyArn ? { kmsKeyArn: options.kmsKeyArn } : {}), clientToken: generateClientToken(), }; diff --git a/src/cli/operations/recommendation/run-recommendation.ts b/src/cli/operations/recommendation/run-recommendation.ts index 42ff863cc..8fb61345b 100644 --- a/src/cli/operations/recommendation/run-recommendation.ts +++ b/src/cli/operations/recommendation/run-recommendation.ts @@ -210,6 +210,7 @@ export async function runRecommendationCommand( name: recommendationName, type: options.type, recommendationConfig, + ...(options.kmsKeyArn ? { kmsKeyArn: options.kmsKeyArn } : {}), }; logger?.log(`Request payload:\n${JSON.stringify(startPayload, null, 2)}`); diff --git a/src/cli/operations/recommendation/types.ts b/src/cli/operations/recommendation/types.ts index 487681a2e..4a822cbe1 100644 --- a/src/cli/operations/recommendation/types.ts +++ b/src/cli/operations/recommendation/types.ts @@ -47,6 +47,8 @@ export interface RunRecommendationCommandOptions { region?: string; /** Optional recommendation name */ recommendationName?: string; + /** KMS key ARN for encrypting recommendation results */ + kmsKeyArn?: string; /** Poll interval in ms */ pollIntervalMs?: number; /** Max polling duration in ms before timing out */ diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index b044d5464..e53994486 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -164,6 +164,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { traceSource: config.traceSource, lookbackDays: config.days, sessionIds: config.sessionIds.length > 0 ? config.sessionIds : undefined, + kmsKeyArn: config.kmsKeyArn || undefined, onProgress: (status, _message) => { if (cancelled) return; const hasFetchStep = config.traceSource === 'sessions'; diff --git a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx index adac72ef8..28790fa6d 100644 --- a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx @@ -1,3 +1,4 @@ +import { isValidKmsKeyArn } from '../../../../schema'; import { detectRegion } from '../../../aws/region'; import type { SessionInfo } from '../../../operations/eval'; import { discoverSessions } from '../../../operations/eval'; @@ -130,6 +131,7 @@ export function RecommendationScreen({ const isTraceSourceStep = wizard.step === 'traceSource'; const isDaysStep = wizard.step === 'days'; const isSessionsStep = wizard.step === 'sessions'; + const isKmsKeyArnStep = wizard.step === 'kms-key-arn'; const isConfirmStep = wizard.step === 'confirm'; const isSystemPrompt = wizard.config.type === 'SYSTEM_PROMPT_RECOMMENDATION'; @@ -404,6 +406,10 @@ export function RecommendationScreen({ confirmFields.push({ label: 'Tools', value: wizard.config.tools || '(none)' }); } + if (wizard.config.kmsKeyArn) { + confirmFields.push({ label: 'KMS Key ARN', value: wizard.config.kmsKeyArn }); + } + // ── Render ──────────────────────────────────────────────────────────────── return ( @@ -592,6 +598,24 @@ export function RecommendationScreen({ /> )} + {isKmsKeyArnStep && ( + wizard.goBack()} + customValidation={value => { + if (!value) return true; + if (!isValidKmsKeyArn(value)) { + return 'Invalid KMS key ARN (e.g. arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012)'; + } + return true; + }} + /> + )} + {isConfirmStep && } diff --git a/src/cli/tui/screens/recommendation/types.ts b/src/cli/tui/screens/recommendation/types.ts index 587ea4a20..34c183e42 100644 --- a/src/cli/tui/screens/recommendation/types.ts +++ b/src/cli/tui/screens/recommendation/types.ts @@ -16,6 +16,7 @@ export type RecommendationStep = | 'traceSource' | 'days' | 'sessions' + | 'kms-key-arn' | 'confirm'; export interface RecommendationWizardConfig { @@ -35,6 +36,8 @@ export interface RecommendationWizardConfig { systemPromptJsonPath: string; /** Tool name → JSONPath pairs for tool descriptions within the config bundle */ toolDescJsonPaths: { toolName: string; toolDescriptionJsonPath: string }[]; + /** KMS key ARN for encrypting recommendation results */ + kmsKeyArn: string; } export const RECOMMENDATION_STEP_LABELS: Record = { @@ -49,6 +52,7 @@ export const RECOMMENDATION_STEP_LABELS: Record = { traceSource: 'Traces', days: 'Lookback', sessions: 'Sessions', + 'kms-key-arn': 'KMS Key', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts index 94c3c66d1..fb3e4af4d 100644 --- a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts +++ b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts @@ -50,6 +50,7 @@ function getAllSteps( steps.push('days'); } + steps.push('kms-key-arn'); steps.push('confirm'); return steps; } @@ -70,6 +71,7 @@ function getDefaultConfig(): RecommendationWizardConfig { bundleFields: [], systemPromptJsonPath: '', toolDescJsonPaths: [], + kmsKeyArn: '', }; } @@ -205,6 +207,14 @@ export function useRecommendationWizard() { [advance] ); + const setKmsKeyArn = useCallback( + (kmsKeyArn: string) => { + setConfig(c => ({ ...c, kmsKeyArn })); + advance('kms-key-arn'); + }, + [advance] + ); + const reset = useCallback(() => { setConfig(getDefaultConfig()); setStep('type'); @@ -227,6 +237,7 @@ export function useRecommendationWizard() { setTraceSource, setDays, setSessions, + setKmsKeyArn, reset, }; } diff --git a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx index a9935d28f..6cd908c7a 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -1,3 +1,4 @@ +import { isValidKmsKeyArn } from '../../../../schema'; import { validateAwsCredentials } from '../../../aws/account'; import { stopBatchEvaluation } from '../../../aws/agentcore-batch-evaluation'; import type { SessionMetadataEntry } from '../../../aws/agentcore-batch-evaluation'; @@ -44,7 +45,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' const DEFAULT_LOOKBACK_DAYS = 7; -type BatchEvalStep = 'agent' | 'evaluators' | 'days' | 'sessions' | 'ground-truth' | 'name' | 'confirm'; +type BatchEvalStep = 'agent' | 'evaluators' | 'days' | 'sessions' | 'ground-truth' | 'kms-key-arn' | 'name' | 'confirm'; interface BatchEvalConfig { agent: string; @@ -54,6 +55,7 @@ interface BatchEvalConfig { sessionIds: string[]; groundTruthFile: string; sessionMetadata?: SessionMetadataEntry[]; + kmsKeyArn: string; name: string; dataset?: string; datasetVersion?: string; @@ -65,6 +67,7 @@ const STEP_LABELS: Record = { days: 'Lookback', sessions: 'Sessions', 'ground-truth': 'Ground Truth', + 'kms-key-arn': 'KMS Key', name: 'Name', confirm: 'Confirm', }; @@ -250,6 +253,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { sessionIds: config.sessionIds.length > 0 ? config.sessionIds : undefined, lookbackDays: config.days, sessionMetadata: config.sessionMetadata, + kmsKeyArn: config.kmsKeyArn || undefined, dataset: config.dataset, datasetVersion: config.datasetVersion, onProgress: (status, _message) => { @@ -460,11 +464,13 @@ function BatchEvalWizard({ const isDatasetMode = source === 'dataset'; const allSteps = useMemo(() => { if (isDatasetMode) { - return skipAgent ? ['evaluators', 'name', 'confirm'] : ['agent', 'evaluators', 'name', 'confirm']; + return skipAgent + ? ['evaluators', 'kms-key-arn', 'name', 'confirm'] + : ['agent', 'evaluators', 'kms-key-arn', 'name', 'confirm']; } return skipAgent - ? ['evaluators', 'days', 'sessions', 'ground-truth', 'name', 'confirm'] - : ['agent', 'evaluators', 'days', 'sessions', 'ground-truth', 'name', 'confirm']; + ? ['evaluators', 'days', 'sessions', 'ground-truth', 'kms-key-arn', 'name', 'confirm'] + : ['agent', 'evaluators', 'days', 'sessions', 'ground-truth', 'kms-key-arn', 'name', 'confirm']; }, [skipAgent, isDatasetMode]); const [step, setStep] = useState(allSteps[0]!); @@ -476,6 +482,7 @@ function BatchEvalWizard({ sessionIds: [], groundTruthFile: '', sessionMetadata: undefined, + kmsKeyArn: '', name: '', }); @@ -523,6 +530,7 @@ function BatchEvalWizard({ const isDaysStep = step === 'days'; const isSessionsStep = step === 'sessions'; const isGroundTruthStep = step === 'ground-truth'; + const isKmsKeyArnStep = step === 'kms-key-arn'; const isNameStep = step === 'name'; const isConfirmStep = step === 'confirm'; @@ -846,6 +854,27 @@ function BatchEvalWizard({ /> )} + {isKmsKeyArnStep && ( + { + setConfig(c => ({ ...c, kmsKeyArn: value })); + goNext(); + }} + onCancel={() => goBack()} + customValidation={value => { + if (!value) return true; + if (!isValidKmsKeyArn(value)) { + return 'Invalid KMS key ARN (e.g. arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012)'; + } + return true; + }} + /> + )} + {isNameStep && ( Optional — leave blank for auto-generated name. @@ -885,6 +914,7 @@ function BatchEvalWizard({ ] : []), ]), + ...(config.kmsKeyArn ? [{ label: 'KMS Key ARN', value: config.kmsKeyArn }] : []), ...(config.name ? [{ label: 'Name', value: config.name }] : []), ]} /> diff --git a/src/schema/schemas/primitives/__tests__/evaluator.test.ts b/src/schema/schemas/primitives/__tests__/evaluator.test.ts index f378e526b..379176244 100644 --- a/src/schema/schemas/primitives/__tests__/evaluator.test.ts +++ b/src/schema/schemas/primitives/__tests__/evaluator.test.ts @@ -5,6 +5,7 @@ import { EvaluatorNameSchema, NumericalRatingSchema, RatingScaleSchema, + isValidKmsKeyArn, } from '../evaluator'; import { describe, expect, it } from 'vitest'; @@ -155,3 +156,45 @@ describe('EvaluatorConfigSchema', () => { expect(EvaluatorConfigSchema.safeParse({}).success).toBe(false); }); }); + +describe('isValidKmsKeyArn', () => { + it('accepts valid commercial KMS key ARN', () => { + expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012')).toBe(true); + }); + + it('accepts valid GovCloud KMS key ARN', () => { + expect( + isValidKmsKeyArn('arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/12345678-1234-1234-1234-123456789012') + ).toBe(true); + }); + + it('accepts valid China partition KMS key ARN', () => { + expect(isValidKmsKeyArn('arn:aws-cn:kms:cn-north-1:123456789012:key/12345678-1234-1234-1234-123456789012')).toBe( + true + ); + }); + + it('rejects ARN with wrong service', () => { + expect(isValidKmsKeyArn('arn:aws:s3:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012')).toBe(false); + }); + + it('rejects ARN with alias instead of key', () => { + expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:123456789012:alias/my-key')).toBe(false); + }); + + it('rejects ARN with invalid account ID length', () => { + expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:12345:key/12345678-1234-1234-1234-123456789012')).toBe(false); + }); + + it('rejects ARN with invalid key UUID format', () => { + expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:123456789012:key/not-a-valid-uuid')).toBe(false); + }); + + it('rejects empty string', () => { + expect(isValidKmsKeyArn('')).toBe(false); + }); + + it('rejects random string', () => { + expect(isValidKmsKeyArn('not-an-arn-at-all')).toBe(false); + }); +}); From 06375ecf8d74896f2792b16f2f2f3060e561b0ef Mon Sep 17 00:00:00 2001 From: Gitika <53349492+notgitika@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:08:06 -0400 Subject: [PATCH 22/27] Revert "feat: add CMK support for batch evaluation and recommendation APIs (#223)" (#226) This reverts commit c0357d7c5dbe6e2b580b185d8741c151505e8789. --- .../agentcore-batch-evaluation.test.ts | 357 ------------------ .../agentcore-recommendation.test.ts | 75 ---- src/cli/aws/agentcore-batch-evaluation.ts | 6 - src/cli/commands/run/command.tsx | 6 - .../operations/eval/run-batch-evaluation.ts | 3 - .../recommendation/run-recommendation.ts | 1 - src/cli/operations/recommendation/types.ts | 2 - .../recommendation/RecommendationFlow.tsx | 1 - .../recommendation/RecommendationScreen.tsx | 24 -- src/cli/tui/screens/recommendation/types.ts | 4 - .../recommendation/useRecommendationWizard.ts | 11 - .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 38 +- .../primitives/__tests__/evaluator.test.ts | 43 --- 13 files changed, 4 insertions(+), 567 deletions(-) delete mode 100644 src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts diff --git a/src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts b/src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts deleted file mode 100644 index 262aad0e1..000000000 --- a/src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { - deleteBatchEvaluation, - getBatchEvaluation, - listBatchEvaluations, - startBatchEvaluation, - stopBatchEvaluation, -} from '../agentcore-batch-evaluation.js'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - -vi.mock('../account', () => ({ - getCredentialProvider: vi.fn().mockReturnValue({ - accessKeyId: 'AKID', - secretAccessKey: 'SECRET', - sessionToken: 'TOKEN', - }), -})); - -vi.mock('@smithy/signature-v4', () => ({ - SignatureV4: class { - // eslint-disable-next-line @typescript-eslint/require-await - async sign(request: { headers: Record }) { - return { headers: { ...request.headers, Authorization: 'signed' } }; - } - }, -})); - -vi.mock('@aws-crypto/sha256-js', () => ({ - Sha256: class {}, -})); - -vi.mock('@aws-sdk/credential-provider-node', () => ({ - defaultProvider: vi.fn(), -})); - -function mockJsonResponse(body: unknown, status = 200) { - return { - ok: status >= 200 && status < 300, - status, - headers: new Map([['x-amzn-requestid', 'test-request-id']]), - json: () => Promise.resolve(body), - text: () => Promise.resolve(JSON.stringify(body)), - }; -} - -describe('agentcore-batch-evaluation', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('startBatchEvaluation', () => { - it('sends POST to /evaluations/batch-evaluate with correct body', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - batchEvaluationId: 'batch-123', - batchEvaluationArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:batch-evaluation/batch-123', - batchEvaluationName: 'MyBatchEval', - status: 'PENDING', - }) - ); - - const result = await startBatchEvaluation({ - region: 'us-west-2', - name: 'MyBatchEval', - evaluators: [{ evaluatorId: 'eval-1' }], - dataSourceConfig: { - cloudWatchLogs: { - serviceNames: ['bedrock-agentcore'], - logGroupNames: ['my-log-group'], - }, - }, - }); - - expect(result.batchEvaluationId).toBe('batch-123'); - expect(result.name).toBe('MyBatchEval'); - expect(result.status).toBe('PENDING'); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/evaluations/batch-evaluate'), - expect.objectContaining({ method: 'POST' }) - ); - - const fetchCall = mockFetch.mock.calls[0]!; - const body = JSON.parse(fetchCall[1].body); - expect(body.batchEvaluationName).toBe('MyBatchEval'); - expect(body.evaluators).toEqual([{ evaluatorId: 'eval-1' }]); - }); - - it('includes kmsKeyArn when provided', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - batchEvaluationId: 'batch-123', - batchEvaluationArn: 'arn:batch-123', - batchEvaluationName: 'MyBatchEval', - status: 'PENDING', - }) - ); - - await startBatchEvaluation({ - region: 'us-west-2', - name: 'MyBatchEval', - evaluators: [{ evaluatorId: 'eval-1' }], - dataSourceConfig: { - cloudWatchLogs: { - serviceNames: ['bedrock-agentcore'], - logGroupNames: ['my-log-group'], - }, - }, - kmsKeyArn: 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012', - }); - - const body = JSON.parse(mockFetch.mock.calls[0]![1].body); - expect(body.kmsKeyArn).toBe('arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'); - }); - - it('omits kmsKeyArn when not provided', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - batchEvaluationId: 'batch-123', - batchEvaluationArn: 'arn:batch-123', - batchEvaluationName: 'MyBatchEval', - status: 'PENDING', - }) - ); - - await startBatchEvaluation({ - region: 'us-west-2', - name: 'MyBatchEval', - evaluators: [{ evaluatorId: 'eval-1' }], - dataSourceConfig: { - cloudWatchLogs: { - serviceNames: ['bedrock-agentcore'], - logGroupNames: ['my-log-group'], - }, - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0]![1].body); - expect(body.kmsKeyArn).toBeUndefined(); - }); - - it('includes description when provided', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - batchEvaluationId: 'batch-123', - batchEvaluationArn: 'arn:batch-123', - batchEvaluationName: 'MyBatchEval', - status: 'PENDING', - }) - ); - - await startBatchEvaluation({ - region: 'us-west-2', - name: 'MyBatchEval', - evaluators: [{ evaluatorId: 'eval-1' }], - dataSourceConfig: { - cloudWatchLogs: { - serviceNames: ['bedrock-agentcore'], - logGroupNames: ['my-log-group'], - }, - }, - description: 'Test evaluation run', - }); - - const body = JSON.parse(mockFetch.mock.calls[0]![1].body); - expect(body.description).toBe('Test evaluation run'); - }); - - it('includes clientToken when provided', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - batchEvaluationId: 'batch-123', - batchEvaluationArn: 'arn:batch-123', - batchEvaluationName: 'MyBatchEval', - status: 'PENDING', - }) - ); - - await startBatchEvaluation({ - region: 'us-west-2', - name: 'MyBatchEval', - evaluators: [{ evaluatorId: 'eval-1' }], - dataSourceConfig: { - cloudWatchLogs: { - serviceNames: ['bedrock-agentcore'], - logGroupNames: ['my-log-group'], - }, - }, - clientToken: 'token-abc', - }); - - const body = JSON.parse(mockFetch.mock.calls[0]![1].body); - expect(body.clientToken).toBe('token-abc'); - }); - - it('includes evaluationMetadata when provided', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - batchEvaluationId: 'batch-123', - batchEvaluationArn: 'arn:batch-123', - batchEvaluationName: 'MyBatchEval', - status: 'PENDING', - }) - ); - - await startBatchEvaluation({ - region: 'us-west-2', - name: 'MyBatchEval', - evaluators: [{ evaluatorId: 'eval-1' }], - dataSourceConfig: { - cloudWatchLogs: { - serviceNames: ['bedrock-agentcore'], - logGroupNames: ['my-log-group'], - }, - }, - evaluationMetadata: { - sessionMetadata: [{ sessionId: 'sess-1', metadata: { referenceAnswer: 'answer' } }], - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0]![1].body); - expect(body.evaluationMetadata.sessionMetadata).toEqual([ - { sessionId: 'sess-1', metadata: { referenceAnswer: 'answer' } }, - ]); - }); - - it('throws on non-ok response', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - headers: new Map([['x-amzn-requestid', 'test-request-id']]), - text: () => Promise.resolve('Bad Request'), - }); - - await expect( - startBatchEvaluation({ - region: 'us-west-2', - name: 'MyBatchEval', - evaluators: [], - dataSourceConfig: { - cloudWatchLogs: { serviceNames: [], logGroupNames: [] }, - }, - }) - ).rejects.toThrow('BatchEvaluation API error (400)'); - }); - }); - - describe('getBatchEvaluation', () => { - it('sends GET to /evaluations/batch-evaluate/{id}', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - batchEvaluationId: 'batch-123', - batchEvaluationArn: 'arn:batch-123', - batchEvaluationName: 'MyBatchEval', - status: 'COMPLETED', - kmsKeyArn: 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012', - }) - ); - - const result = await getBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); - - expect(result.batchEvaluationId).toBe('batch-123'); - expect(result.name).toBe('MyBatchEval'); - expect(result.status).toBe('COMPLETED'); - expect(result.kmsKeyArn).toBe('arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/evaluations/batch-evaluate/batch-123'), - expect.objectContaining({ method: 'GET' }) - ); - }); - - it('returns undefined kmsKeyArn when not present in response', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - batchEvaluationId: 'batch-123', - batchEvaluationArn: 'arn:batch-123', - batchEvaluationName: 'MyBatchEval', - status: 'COMPLETED', - }) - ); - - const result = await getBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); - expect(result.kmsKeyArn).toBeUndefined(); - }); - }); - - describe('listBatchEvaluations', () => { - it('sends GET to /evaluations/batch-evaluate', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - batchEvaluations: [ - { batchEvaluationId: 'b1', name: 'Eval1', status: 'COMPLETED' }, - { batchEvaluationId: 'b2', name: 'Eval2', status: 'PENDING' }, - ], - }) - ); - - const result = await listBatchEvaluations({ region: 'us-west-2' }); - - expect(result.batchEvaluations).toHaveLength(2); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/evaluations/batch-evaluate'), - expect.objectContaining({ method: 'GET' }) - ); - }); - - it('includes maxResults and nextToken query params', async () => { - mockFetch.mockResolvedValue(mockJsonResponse({ batchEvaluations: [], nextToken: undefined })); - - await listBatchEvaluations({ region: 'us-west-2', maxResults: 5, nextToken: 'page2' }); - - const url = mockFetch.mock.calls[0]![0] as string; - expect(url).toContain('maxResults=5'); - expect(url).toContain('nextToken=page2'); - }); - }); - - describe('stopBatchEvaluation', () => { - it('sends POST to /evaluations/batch-evaluate/{id}/stop', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - batchEvaluationId: 'batch-123', - status: 'STOPPING', - }) - ); - - const result = await stopBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); - - expect(result.batchEvaluationId).toBe('batch-123'); - expect(result.status).toBe('STOPPING'); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/evaluations/batch-evaluate/batch-123/stop'), - expect.objectContaining({ method: 'POST' }) - ); - }); - }); - - describe('deleteBatchEvaluation', () => { - it('sends DELETE to /evaluations/batch-evaluate/{id}', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 204, - headers: new Map(), - json: () => Promise.resolve({}), - text: () => Promise.resolve(''), - }); - - await deleteBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/evaluations/batch-evaluate/batch-123'), - expect.objectContaining({ method: 'DELETE' }) - ); - }); - }); -}); diff --git a/src/cli/aws/__tests__/agentcore-recommendation.test.ts b/src/cli/aws/__tests__/agentcore-recommendation.test.ts index 993c28378..1b330cf30 100644 --- a/src/cli/aws/__tests__/agentcore-recommendation.test.ts +++ b/src/cli/aws/__tests__/agentcore-recommendation.test.ts @@ -171,81 +171,6 @@ describe('agentcore-recommendation', () => { expect(body.description).toBe('Test description'); }); - it('includes kmsKeyArn when provided', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - recommendationId: 'r1', - recommendationArn: 'arn:1', - name: 'MyRec', - type: 'SYSTEM_PROMPT_RECOMMENDATION', - status: 'PENDING', - }) - ); - - await startRecommendation({ - region: 'us-west-2', - name: 'MyRec', - type: 'SYSTEM_PROMPT_RECOMMENDATION', - recommendationConfig: { - systemPromptRecommendationConfig: { - systemPrompt: { text: '' }, - agentTraces: { - cloudwatchLogs: { - logGroupArns: [], - serviceNames: ['bedrock-agentcore'], - startTime: '2026-03-23T00:00:00.000Z', - endTime: '2026-03-30T00:00:00.000Z', - }, - }, - evaluationConfig: { - evaluators: [{ evaluatorArn: 'arn:aws:bedrock-agentcore:::evaluator/Builtin.Helpfulness' }], - }, - }, - }, - kmsKeyArn: 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012', - }); - - const body = JSON.parse(mockFetch.mock.calls[0]![1].body); - expect(body.kmsKeyArn).toBe('arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'); - }); - - it('omits kmsKeyArn when not provided', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - recommendationId: 'r1', - recommendationArn: 'arn:1', - name: 'MyRec', - type: 'SYSTEM_PROMPT_RECOMMENDATION', - status: 'PENDING', - }) - ); - - await startRecommendation({ - region: 'us-west-2', - name: 'MyRec', - type: 'SYSTEM_PROMPT_RECOMMENDATION', - recommendationConfig: { - systemPromptRecommendationConfig: { - systemPrompt: { text: '' }, - agentTraces: { - cloudwatchLogs: { - logGroupArns: [], - serviceNames: ['bedrock-agentcore'], - startTime: '2026-03-23T00:00:00.000Z', - endTime: '2026-03-30T00:00:00.000Z', - }, - }, - evaluationConfig: { - evaluators: [{ evaluatorArn: 'arn:aws:bedrock-agentcore:::evaluator/Builtin.Helpfulness' }], - }, - }, - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0]![1].body); - expect(body.kmsKeyArn).toBeUndefined(); - }); - it('throws on non-ok response', async () => { mockFetch.mockResolvedValue({ ok: false, diff --git a/src/cli/aws/agentcore-batch-evaluation.ts b/src/cli/aws/agentcore-batch-evaluation.ts index 986e238d3..9b0923753 100644 --- a/src/cli/aws/agentcore-batch-evaluation.ts +++ b/src/cli/aws/agentcore-batch-evaluation.ts @@ -99,7 +99,6 @@ export interface StartBatchEvaluationOptions { evaluationMetadata?: EvaluationMetadata; description?: string; clientToken?: string; - kmsKeyArn?: string; } export interface StartBatchEvaluationResult { @@ -160,7 +159,6 @@ export interface GetBatchEvaluationResult { evaluationResults?: EvaluationResults; errorDetails?: string[]; description?: string; - kmsKeyArn?: string; } export interface BatchEvaluationResultEntry { @@ -297,9 +295,6 @@ export async function startBatchEvaluation(options: StartBatchEvaluationOptions) if (options.clientToken) { body.clientToken = options.clientToken; } - if (options.kmsKeyArn) { - body.kmsKeyArn = options.kmsKeyArn; - } const { data } = await signedRequest({ region: options.region, @@ -342,7 +337,6 @@ export async function getBatchEvaluation(options: GetBatchEvaluationOptions): Pr evaluationResults: raw.evaluationResults as EvaluationResults | undefined, errorDetails: raw.errorDetails as string[] | undefined, description: raw.description as string | undefined, - kmsKeyArn: raw.kmsKeyArn as string | undefined, }; } diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index b8d9bf3d5..091d95727 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -195,7 +195,6 @@ export const registerRun = (program: Command) => { ) .option('--dataset ', 'Dataset name — invoke agent with dataset scenarios before batch evaluation') .option('--dataset-version ', 'Dataset version to use (omit for local file, or N/DRAFT)') - .option('--kms-key ', 'KMS key ARN for encrypting batch evaluation results') .option('--json', 'Output as JSON') .action( async (cliOptions: { @@ -209,7 +208,6 @@ export const registerRun = (program: Command) => { endpoint?: string; dataset?: string; datasetVersion?: string; - kmsKey?: string; json?: boolean; }) => { requireProject(); @@ -244,7 +242,6 @@ export const registerRun = (program: Command) => { sessionMetadata, dataset: cliOptions.dataset, datasetVersion: cliOptions.datasetVersion, - kmsKeyArn: cliOptions.kmsKey, onProgress: cliOptions.json ? undefined : (_status, message) => { @@ -323,7 +320,6 @@ export const registerRun = (program: Command) => { .option('-s, --session-id ', 'Limit trace collection to specific session IDs') .option('-n, --run ', 'Run name prefix for the recommendation') .option('--region ', 'AWS region') - .option('--kms-key ', 'KMS key ARN for encrypting recommendation results') .option('--json', 'Output as JSON') .action( async (cliOptions: { @@ -342,7 +338,6 @@ export const registerRun = (program: Command) => { sessionId?: string[]; run?: string; region?: string; - kmsKey?: string; json?: boolean; }) => { requireProject(); @@ -426,7 +421,6 @@ export const registerRun = (program: Command) => { spansFile: cliOptions.spansFile, recommendationName: cliOptions.run, region: cliOptions.region, - kmsKeyArn: cliOptions.kmsKey, inputSource, traceSource, onProgress: cliOptions.json diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index accd3b711..4b68456ed 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -54,8 +54,6 @@ export interface RunBatchEvaluationOptions { datasetVersion?: string; /** Runtime endpoint name (e.g. PROMPT_V1). Defaults to DEFAULT. */ endpoint?: string; - /** KMS key ARN for encrypting batch evaluation results */ - kmsKeyArn?: string; } export interface BatchEvaluationResult { @@ -281,7 +279,6 @@ export async function runBatchEvaluationCommand( }, }, ...(allSessionMetadata.length > 0 ? { evaluationMetadata: { sessionMetadata: allSessionMetadata } } : {}), - ...(options.kmsKeyArn ? { kmsKeyArn: options.kmsKeyArn } : {}), clientToken: generateClientToken(), }; diff --git a/src/cli/operations/recommendation/run-recommendation.ts b/src/cli/operations/recommendation/run-recommendation.ts index 8fb61345b..42ff863cc 100644 --- a/src/cli/operations/recommendation/run-recommendation.ts +++ b/src/cli/operations/recommendation/run-recommendation.ts @@ -210,7 +210,6 @@ export async function runRecommendationCommand( name: recommendationName, type: options.type, recommendationConfig, - ...(options.kmsKeyArn ? { kmsKeyArn: options.kmsKeyArn } : {}), }; logger?.log(`Request payload:\n${JSON.stringify(startPayload, null, 2)}`); diff --git a/src/cli/operations/recommendation/types.ts b/src/cli/operations/recommendation/types.ts index 4a822cbe1..487681a2e 100644 --- a/src/cli/operations/recommendation/types.ts +++ b/src/cli/operations/recommendation/types.ts @@ -47,8 +47,6 @@ export interface RunRecommendationCommandOptions { region?: string; /** Optional recommendation name */ recommendationName?: string; - /** KMS key ARN for encrypting recommendation results */ - kmsKeyArn?: string; /** Poll interval in ms */ pollIntervalMs?: number; /** Max polling duration in ms before timing out */ diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index e53994486..b044d5464 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -164,7 +164,6 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { traceSource: config.traceSource, lookbackDays: config.days, sessionIds: config.sessionIds.length > 0 ? config.sessionIds : undefined, - kmsKeyArn: config.kmsKeyArn || undefined, onProgress: (status, _message) => { if (cancelled) return; const hasFetchStep = config.traceSource === 'sessions'; diff --git a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx index 28790fa6d..adac72ef8 100644 --- a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx @@ -1,4 +1,3 @@ -import { isValidKmsKeyArn } from '../../../../schema'; import { detectRegion } from '../../../aws/region'; import type { SessionInfo } from '../../../operations/eval'; import { discoverSessions } from '../../../operations/eval'; @@ -131,7 +130,6 @@ export function RecommendationScreen({ const isTraceSourceStep = wizard.step === 'traceSource'; const isDaysStep = wizard.step === 'days'; const isSessionsStep = wizard.step === 'sessions'; - const isKmsKeyArnStep = wizard.step === 'kms-key-arn'; const isConfirmStep = wizard.step === 'confirm'; const isSystemPrompt = wizard.config.type === 'SYSTEM_PROMPT_RECOMMENDATION'; @@ -406,10 +404,6 @@ export function RecommendationScreen({ confirmFields.push({ label: 'Tools', value: wizard.config.tools || '(none)' }); } - if (wizard.config.kmsKeyArn) { - confirmFields.push({ label: 'KMS Key ARN', value: wizard.config.kmsKeyArn }); - } - // ── Render ──────────────────────────────────────────────────────────────── return ( @@ -598,24 +592,6 @@ export function RecommendationScreen({ /> )} - {isKmsKeyArnStep && ( - wizard.goBack()} - customValidation={value => { - if (!value) return true; - if (!isValidKmsKeyArn(value)) { - return 'Invalid KMS key ARN (e.g. arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012)'; - } - return true; - }} - /> - )} - {isConfirmStep && } diff --git a/src/cli/tui/screens/recommendation/types.ts b/src/cli/tui/screens/recommendation/types.ts index 34c183e42..587ea4a20 100644 --- a/src/cli/tui/screens/recommendation/types.ts +++ b/src/cli/tui/screens/recommendation/types.ts @@ -16,7 +16,6 @@ export type RecommendationStep = | 'traceSource' | 'days' | 'sessions' - | 'kms-key-arn' | 'confirm'; export interface RecommendationWizardConfig { @@ -36,8 +35,6 @@ export interface RecommendationWizardConfig { systemPromptJsonPath: string; /** Tool name → JSONPath pairs for tool descriptions within the config bundle */ toolDescJsonPaths: { toolName: string; toolDescriptionJsonPath: string }[]; - /** KMS key ARN for encrypting recommendation results */ - kmsKeyArn: string; } export const RECOMMENDATION_STEP_LABELS: Record = { @@ -52,7 +49,6 @@ export const RECOMMENDATION_STEP_LABELS: Record = { traceSource: 'Traces', days: 'Lookback', sessions: 'Sessions', - 'kms-key-arn': 'KMS Key', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts index fb3e4af4d..94c3c66d1 100644 --- a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts +++ b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts @@ -50,7 +50,6 @@ function getAllSteps( steps.push('days'); } - steps.push('kms-key-arn'); steps.push('confirm'); return steps; } @@ -71,7 +70,6 @@ function getDefaultConfig(): RecommendationWizardConfig { bundleFields: [], systemPromptJsonPath: '', toolDescJsonPaths: [], - kmsKeyArn: '', }; } @@ -207,14 +205,6 @@ export function useRecommendationWizard() { [advance] ); - const setKmsKeyArn = useCallback( - (kmsKeyArn: string) => { - setConfig(c => ({ ...c, kmsKeyArn })); - advance('kms-key-arn'); - }, - [advance] - ); - const reset = useCallback(() => { setConfig(getDefaultConfig()); setStep('type'); @@ -237,7 +227,6 @@ export function useRecommendationWizard() { setTraceSource, setDays, setSessions, - setKmsKeyArn, reset, }; } diff --git a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx index 6cd908c7a..a9935d28f 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -1,4 +1,3 @@ -import { isValidKmsKeyArn } from '../../../../schema'; import { validateAwsCredentials } from '../../../aws/account'; import { stopBatchEvaluation } from '../../../aws/agentcore-batch-evaluation'; import type { SessionMetadataEntry } from '../../../aws/agentcore-batch-evaluation'; @@ -45,7 +44,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' const DEFAULT_LOOKBACK_DAYS = 7; -type BatchEvalStep = 'agent' | 'evaluators' | 'days' | 'sessions' | 'ground-truth' | 'kms-key-arn' | 'name' | 'confirm'; +type BatchEvalStep = 'agent' | 'evaluators' | 'days' | 'sessions' | 'ground-truth' | 'name' | 'confirm'; interface BatchEvalConfig { agent: string; @@ -55,7 +54,6 @@ interface BatchEvalConfig { sessionIds: string[]; groundTruthFile: string; sessionMetadata?: SessionMetadataEntry[]; - kmsKeyArn: string; name: string; dataset?: string; datasetVersion?: string; @@ -67,7 +65,6 @@ const STEP_LABELS: Record = { days: 'Lookback', sessions: 'Sessions', 'ground-truth': 'Ground Truth', - 'kms-key-arn': 'KMS Key', name: 'Name', confirm: 'Confirm', }; @@ -253,7 +250,6 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { sessionIds: config.sessionIds.length > 0 ? config.sessionIds : undefined, lookbackDays: config.days, sessionMetadata: config.sessionMetadata, - kmsKeyArn: config.kmsKeyArn || undefined, dataset: config.dataset, datasetVersion: config.datasetVersion, onProgress: (status, _message) => { @@ -464,13 +460,11 @@ function BatchEvalWizard({ const isDatasetMode = source === 'dataset'; const allSteps = useMemo(() => { if (isDatasetMode) { - return skipAgent - ? ['evaluators', 'kms-key-arn', 'name', 'confirm'] - : ['agent', 'evaluators', 'kms-key-arn', 'name', 'confirm']; + return skipAgent ? ['evaluators', 'name', 'confirm'] : ['agent', 'evaluators', 'name', 'confirm']; } return skipAgent - ? ['evaluators', 'days', 'sessions', 'ground-truth', 'kms-key-arn', 'name', 'confirm'] - : ['agent', 'evaluators', 'days', 'sessions', 'ground-truth', 'kms-key-arn', 'name', 'confirm']; + ? ['evaluators', 'days', 'sessions', 'ground-truth', 'name', 'confirm'] + : ['agent', 'evaluators', 'days', 'sessions', 'ground-truth', 'name', 'confirm']; }, [skipAgent, isDatasetMode]); const [step, setStep] = useState(allSteps[0]!); @@ -482,7 +476,6 @@ function BatchEvalWizard({ sessionIds: [], groundTruthFile: '', sessionMetadata: undefined, - kmsKeyArn: '', name: '', }); @@ -530,7 +523,6 @@ function BatchEvalWizard({ const isDaysStep = step === 'days'; const isSessionsStep = step === 'sessions'; const isGroundTruthStep = step === 'ground-truth'; - const isKmsKeyArnStep = step === 'kms-key-arn'; const isNameStep = step === 'name'; const isConfirmStep = step === 'confirm'; @@ -854,27 +846,6 @@ function BatchEvalWizard({ /> )} - {isKmsKeyArnStep && ( - { - setConfig(c => ({ ...c, kmsKeyArn: value })); - goNext(); - }} - onCancel={() => goBack()} - customValidation={value => { - if (!value) return true; - if (!isValidKmsKeyArn(value)) { - return 'Invalid KMS key ARN (e.g. arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012)'; - } - return true; - }} - /> - )} - {isNameStep && ( Optional — leave blank for auto-generated name. @@ -914,7 +885,6 @@ function BatchEvalWizard({ ] : []), ]), - ...(config.kmsKeyArn ? [{ label: 'KMS Key ARN', value: config.kmsKeyArn }] : []), ...(config.name ? [{ label: 'Name', value: config.name }] : []), ]} /> diff --git a/src/schema/schemas/primitives/__tests__/evaluator.test.ts b/src/schema/schemas/primitives/__tests__/evaluator.test.ts index 379176244..f378e526b 100644 --- a/src/schema/schemas/primitives/__tests__/evaluator.test.ts +++ b/src/schema/schemas/primitives/__tests__/evaluator.test.ts @@ -5,7 +5,6 @@ import { EvaluatorNameSchema, NumericalRatingSchema, RatingScaleSchema, - isValidKmsKeyArn, } from '../evaluator'; import { describe, expect, it } from 'vitest'; @@ -156,45 +155,3 @@ describe('EvaluatorConfigSchema', () => { expect(EvaluatorConfigSchema.safeParse({}).success).toBe(false); }); }); - -describe('isValidKmsKeyArn', () => { - it('accepts valid commercial KMS key ARN', () => { - expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012')).toBe(true); - }); - - it('accepts valid GovCloud KMS key ARN', () => { - expect( - isValidKmsKeyArn('arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/12345678-1234-1234-1234-123456789012') - ).toBe(true); - }); - - it('accepts valid China partition KMS key ARN', () => { - expect(isValidKmsKeyArn('arn:aws-cn:kms:cn-north-1:123456789012:key/12345678-1234-1234-1234-123456789012')).toBe( - true - ); - }); - - it('rejects ARN with wrong service', () => { - expect(isValidKmsKeyArn('arn:aws:s3:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012')).toBe(false); - }); - - it('rejects ARN with alias instead of key', () => { - expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:123456789012:alias/my-key')).toBe(false); - }); - - it('rejects ARN with invalid account ID length', () => { - expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:12345:key/12345678-1234-1234-1234-123456789012')).toBe(false); - }); - - it('rejects ARN with invalid key UUID format', () => { - expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:123456789012:key/not-a-valid-uuid')).toBe(false); - }); - - it('rejects empty string', () => { - expect(isValidKmsKeyArn('')).toBe(false); - }); - - it('rejects random string', () => { - expect(isValidKmsKeyArn('not-an-arn-at-all')).toBe(false); - }); -}); From a4c4e3d1af2adb43949f55bda6e9d4f5b6250c70 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:36:35 -0400 Subject: [PATCH 23/27] ci: add Claude Code /security-review workflow on PRs (#267) --- .github/workflows/pr-security-review.yml | 365 +++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 .github/workflows/pr-security-review.yml diff --git a/.github/workflows/pr-security-review.yml b/.github/workflows/pr-security-review.yml new file mode 100644 index 000000000..697cdc7b1 --- /dev/null +++ b/.github/workflows/pr-security-review.yml @@ -0,0 +1,365 @@ +name: Claude Security Review + +on: + pull_request_target: + types: [opened, reopened, synchronize, labeled] + workflow_dispatch: + inputs: + pr_number: + description: + 'PR number to review (note: workflow_dispatch will NOT post inline comments — the action only attaches the + inline-comment MCP server on PR-context events. Use this only for end-to-end smoke-testing the prompt + plumbing.)' + required: true + type: string + +permissions: + id-token: write + pull-requests: write + issues: write + contents: read + +concurrency: + # Don't cancel-in-progress: a cancelled run that has already started its labels/checkout + # but not the actual review still triggers always() steps and ends up posting a misleading + # "no findings" summary (since the inline-comment buffer is empty when the analysis step + # was skipped due to cancellation). Letting both runs complete is the safer default. + group: pr-security-review-${{ github.event.pull_request.number || inputs.pr_number }} + cancel-in-progress: false + +jobs: + authorize: + runs-on: ubuntu-latest + # On 'labeled' events, only proceed when the label is exactly 'safe-to-review'. + # Other labels (e.g. size/m) are filtered out so we don't spawn API calls. + if: | + github.event_name != 'pull_request_target' || + github.event.action != 'labeled' || + github.event.label.name == 'safe-to-review' + outputs: + authorized: ${{ steps.auth.outputs.authorized || steps.dispatch-auth.outputs.authorized }} + steps: + - name: Check authorization + id: auth + if: github.event_name == 'pull_request_target' + uses: actions/github-script@v9 + with: + script: | + // pull_request_target opened/reopened/synchronize: gate on the PR author + // (auto-runs on maintainer-authored PRs; community PRs need the label path below). + // pull_request_target labeled (safe-to-review): gate on the labeler (sender) + // so a maintainer applying the label authorizes the run on a community PR. + const isLabel = context.payload.action === 'labeled'; + const user = isLabel + ? context.payload.sender.login + : context.payload.pull_request.user.login; + const reason = isLabel ? `labeler ${user}` : `PR author ${user}`; + try { + await github.rest.teams.getMembershipForUserInOrg({ + org: context.repo.owner, + team_slug: 'agentcore-cli-devs', + username: user, + }); + console.log(`${reason} is a member of agentcore-cli-devs`); + core.setOutput('authorized', 'true'); + } catch (teamError) { + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: user, + }); + const hasWriteAccess = ['write', 'admin'].includes(data.permission); + if (hasWriteAccess) { + console.log(`${reason} has write access (${data.permission})`); + core.setOutput('authorized', 'true'); + } else { + console.log(`${reason} does not have write access (${data.permission}) — skipping review`); + core.setOutput('authorized', 'false'); + } + } catch (collabError) { + console.log(`${reason} authorization check failed (${collabError.status}) — skipping review`); + core.setOutput('authorized', 'false'); + } + } + + - name: Auto-authorize workflow_dispatch + id: dispatch-auth + if: github.event_name == 'workflow_dispatch' + run: echo "authorized=true" >> "$GITHUB_OUTPUT" + + security-review: + needs: authorize + if: needs.authorize.outputs.authorized == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + AWS_REGION: us-west-2 + steps: + # Generate the GitHub App token first so every subsequent github-script step can + # use it. The default GITHUB_TOKEN is read-only on pull_request_target / + # pull_request_review events from forks, which makes label/comment writes 403. + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Resolve PR number + id: pr + uses: actions/github-script@v9 + env: + PR_NUMBER_INPUT: ${{ inputs.pr_number }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const num = + context.eventName === 'workflow_dispatch' + ? parseInt(process.env.PR_NUMBER_INPUT, 10) + : context.payload.pull_request.number; + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: num, + }); + core.setOutput('number', num); + core.setOutput('head_sha', pr.head.sha); + core.setOutput('base_ref', pr.base.ref); + + - name: Add claude-security-reviewing label + uses: actions/github-script@v9 + env: + PR_NUMBER: ${{ steps.pr.outputs.number }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const prNumber = parseInt(process.env.PR_NUMBER, 10); + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'claude-security-reviewing', + }); + } catch (e) { + if (e.status === 404) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'claude-security-reviewing', + color: 'D73A4A', + description: 'Claude Code /security-review in progress', + }); + } + } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['claude-security-reviewing'], + }); + + - name: Checkout PR head + uses: actions/checkout@v6 + with: + ref: ${{ steps.pr.outputs.head_sha }} + # The bundled /security-review skill runs `git diff origin/HEAD...` so we need + # the base branch locally too. fetch-depth: 0 grabs the full history. + fetch-depth: 0 + + - name: Prepare base ref for /security-review skill + env: + BASE_REF: ${{ steps.pr.outputs.base_ref }} + run: | + set -euo pipefail + # The bundled /security-review skill's SessionStart hook runs + # `git diff --name-only origin/HEAD...` as its first command. Two + # things have to be true for that to succeed: + # 1. origin/HEAD has to be a valid ref. actions/checkout doesn't + # set up the remote's symbolic HEAD, so we point it at the PR's + # base branch. + # 2. The PR head and origin/ have to share a merge base in + # the local clone. actions/checkout@v6 with `ref: ` + # fetches the head's history but on fork PRs may NOT fetch + # origin/ into the clone — leaving `git diff origin/HEAD...` + # to fail with "no merge base", which silently bombs the skill + # (num_turns=0, model never invoked) and would otherwise look + # like a clean review with zero findings. + # Fetching origin/ explicitly closes that gap. + git fetch --no-tags origin "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" + git remote set-head origin "$BASE_REF" + git symbolic-ref refs/remotes/origin/HEAD + + # Sanity: ensure the merge base actually resolves before we hand off + # to the skill. If it doesn't, fail loudly here rather than letting + # the skill silently bail. + if ! git merge-base "origin/$BASE_REF" HEAD >/dev/null; then + echo "::error::No merge base between HEAD and origin/$BASE_REF — /security-review cannot compute a diff." + exit 1 + fi + echo "Merge base: $(git merge-base "origin/$BASE_REF" HEAD)" + echo "Files changed: $(git diff --name-only "origin/$BASE_REF...HEAD" | wc -l)" + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.BEDROCK_SECURITY_REVIEW_ROLE_ARN }} + aws-region: us-west-2 + + - name: Run Claude Code security review + id: review + uses: anthropics/claude-code-action@v1 + with: + github_token: ${{ steps.app-token.outputs.token }} + use_bedrock: 'true' + # The Claude Code SDK that ships with the action has /security-review bundled + # as a slash command. Invoking it directly lets the skill drive its own + # `git diff origin/HEAD...`, sub-task fan-out, and false-positive filtering + # without us re-implementing any of that. We append a short tail telling the + # action to use the inline-comment MCP tool for findings. + prompt: | + /security-review + + For each finding, call mcp__github_inline_comment__create_inline_comment with + { path, line, body } pointing at the exact file and line in the diff. Do NOT + post a single summary comment listing all findings — the workflow handles a + top-level summary after this run completes. If there are no findings, exit + without calling any tool. + show_full_output: 'true' + # Allow-listing this MCP tool name is what tells the action to register the + # github_inline_comment MCP server. See anthropics/claude-code-action + # src/mcp/install-mcp-server.ts. + claude_args: >- + --model us.anthropic.claude-opus-4-7 --max-turns 30 --allowedTools + mcp__github_inline_comment__create_inline_comment + + - name: Verify model actually ran + id: model-ran + # The action exits 0 even when the model was never invoked (e.g. a + # SessionStart hook errored before the first turn). Treating that as + # success would let the workflow falsely report "no high-confidence + # findings" — that's exactly what happened on PR #1474, where the + # `/security-review` skill's first `git diff origin/HEAD...` hit a + # "no merge base" error, the SDK still returned `subtype: success` + # with `num_turns: 0` and zero tokens, and the buffer was empty. + # + # The action writes its full SDK transcript to + # ${RUNNER_TEMP}/claude-execution-output.json. We pull the final + # result envelope and require that the model actually took turns and + # spent tokens. If it didn't, mark the run as not-actually-completed + # so the summary comment uses the failure branch instead of pretending + # there were no findings. + if: steps.review.conclusion == 'success' || steps.review.conclusion == 'failure' + env: + # The action exposes its full SDK transcript path as the + # `execution_file` step output (a JSON array of stream events; the + # final element is the result envelope). We fall back to the known + # path under RUNNER_TEMP if for some reason the output is missing. + OUTPUT_JSON: + ${{ steps.review.outputs.execution_file || format('{0}/claude-execution-output.json', runner.temp) }} + run: | + set -euo pipefail + if [ ! -s "$OUTPUT_JSON" ]; then + echo "::warning::No claude-execution-output.json found at $OUTPUT_JSON; cannot verify the model ran." + echo "ran=unknown" >> "$GITHUB_OUTPUT" + echo "num_turns=0" >> "$GITHUB_OUTPUT" + exit 0 + fi + NUM_TURNS=$(jq -r '.[-1].num_turns // 0' "$OUTPUT_JSON") + IS_ERROR=$(jq -r '.[-1].is_error // false' "$OUTPUT_JSON") + OUTPUT_TOKENS=$(jq -r '.[-1].usage.output_tokens // 0' "$OUTPUT_JSON") + echo "num_turns=$NUM_TURNS, is_error=$IS_ERROR, output_tokens=$OUTPUT_TOKENS" + echo "num_turns=$NUM_TURNS" >> "$GITHUB_OUTPUT" + if [ "$IS_ERROR" = "true" ] || [ "$NUM_TURNS" = "0" ] || [ "$OUTPUT_TOKENS" = "0" ]; then + echo "::error::Claude Code SDK reported success but the model never ran productively (num_turns=$NUM_TURNS, output_tokens=$OUTPUT_TOKENS, is_error=$IS_ERROR). The /security-review skill likely bailed before analysis (e.g. SessionStart hook error). Refusing to report 'no findings'." + echo "ran=false" >> "$GITHUB_OUTPUT" + exit 1 + fi + echo "ran=true" >> "$GITHUB_OUTPUT" + + - name: Count buffered findings + id: findings + # Only count if the review step actually ran (success or failure - both produce + # a meaningful buffer state). Skip on cancellation/skip so we don't lie about + # "no findings" when Bedrock was never invoked. + if: steps.review.conclusion == 'success' || steps.review.conclusion == 'failure' + run: | + set -euo pipefail + BUFFER=/tmp/inline-comments-buffer.jsonl + if [ -s "$BUFFER" ]; then + COUNT=$(wc -l < "$BUFFER" | tr -d ' ') + else + COUNT=0 + fi + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + echo "Buffered findings: $COUNT" + + - name: Post security review summary comment + # Always post some kind of summary so the PR shows the run happened, but branch on + # the review step's conclusion so a cancelled/skipped run doesn't get reported as + # "no findings". + if: always() + uses: actions/github-script@v9 + env: + PR_NUMBER: ${{ steps.pr.outputs.number }} + FINDING_COUNT: ${{ steps.findings.outputs.count }} + REVIEW_CONCLUSION: ${{ steps.review.conclusion }} + MODEL_RAN: ${{ steps.model-ran.outputs.ran }} + NUM_TURNS: ${{ steps.model-ran.outputs.num_turns }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const prNumber = parseInt(process.env.PR_NUMBER, 10); + const count = parseInt(process.env.FINDING_COUNT || '0', 10); + const conclusion = process.env.REVIEW_CONCLUSION || 'skipped'; + const modelRan = process.env.MODEL_RAN || 'unknown'; + const numTurns = process.env.NUM_TURNS || '0'; + const runUrl = process.env.RUN_URL; + + let body; + if (modelRan !== 'true') { + // Two cases land here, both unsafe to report as "no findings": + // - 'false': SDK exited 0 but transcript shows the model never ran productively + // (e.g. /security-review's SessionStart hook errored before the first turn). + // - 'unknown': claude-execution-output.json was missing, so we couldn't verify + // the model ran at all. Treat as not-verified rather than silently green. + body = `**Claude Security Review:** the review did not actually analyze this PR (model took ${numTurns} turn${numTurns === '1' ? '' : 's'} — the skill likely failed during setup). See the [run](${runUrl}) for details; a later push or re-run is needed for a real review.`; + } else if (conclusion === 'success') { + body = + count > 0 + ? `**Claude Security Review:** posted ${count} inline finding${count === 1 ? '' : 's'} on this PR. ([run](${runUrl}))` + : `**Claude Security Review:** no high-confidence findings. ([run](${runUrl}))`; + } else if (conclusion === 'failure') { + body = `**Claude Security Review:** the review run failed before completing. See the [run](${runUrl}) for details.`; + } else { + // cancelled / skipped — analysis didn't run, do NOT claim "no findings" + body = `**Claude Security Review:** the review run was ${conclusion} before the analysis could complete (likely superseded or interrupted). See the [run](${runUrl}); a later run on this PR will replace this status.`; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + + - name: Remove claude-security-reviewing label + if: always() + uses: actions/github-script@v9 + env: + PR_NUMBER: ${{ steps.pr.outputs.number }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const prNumber = parseInt(process.env.PR_NUMBER, 10); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'claude-security-reviewing', + }); + } catch (error) { + console.log('Label removal failed (may not exist):', error.message); + } From b8fd2db1307f17f81a37748defaaa9f2609e450d Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:07:19 -0400 Subject: [PATCH 24/27] fix(ci): make security review robust to shallow-clone restoration (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as the merge to feat/summit_release in #291. main's copy of the workflow lives at pr-security-review.yml (not pr-security-reviewer.yml like the summit branch); both branches need the inlined-prompt fix since each runs the workflow definition from its own base on every PR. Root cause: anthropics/claude-code-action@v1's restoreConfigFromBase() runs `git fetch origin --depth=1` on startup, which shallows the runner's clone. The bundled /security-review skill's first action is `git diff origin/HEAD...`, which fails with "no merge base" on a shallow clone — so the SDK exits clean with num_turns=0 and the workflow had no choice but to fail. Replace the bundled skill with an inline prompt: compute the diff to /tmp/pr.diff before claude-code-action runs, then have the model read that file plus the working tree (Read/Grep/Glob/Task) and post findings via the inline-comment MCP tool. Restrict the trigger to PRs targeting main and feat/summit_release. --- .github/workflows/pr-security-review.yml | 368 +++++++++++++++-------- 1 file changed, 243 insertions(+), 125 deletions(-) diff --git a/.github/workflows/pr-security-review.yml b/.github/workflows/pr-security-review.yml index 697cdc7b1..f94d91572 100644 --- a/.github/workflows/pr-security-review.yml +++ b/.github/workflows/pr-security-review.yml @@ -1,15 +1,29 @@ name: Claude Security Review +# This workflow inlines the security-review prompt rather than calling the +# bundled /security-review slash command. The bundled skill silently bombs +# whenever the runner's clone gets shallowed mid-run (claude-code-action's +# restoreConfigFromBase does this on every PR by design — see +# https://github.com/anthropics/claude-code-action/blob/v1/src/github/operations/restore-config.ts), +# because its first action is `git diff origin/HEAD...` and a shallow clone +# has no merge base. Computing the diff ourselves before the action starts +# eliminates that whole class of failure. + on: pull_request_target: types: [opened, reopened, synchronize, labeled] + # Only review PRs targeting our two long-lived release branches. PRs + # into short-lived feature branches don't need a security gate — they + # get reviewed when those features are merged into main or + # feat/summit_release. + branches: + - main + - feat/summit_release workflow_dispatch: inputs: pr_number: description: - 'PR number to review (note: workflow_dispatch will NOT post inline comments — the action only attaches the - inline-comment MCP server on PR-context events. Use this only for end-to-end smoke-testing the prompt - plumbing.)' + PR number to review (workflow_dispatch will NOT post inline comments — use only for prompt smoke tests) required: true type: string @@ -45,10 +59,6 @@ jobs: uses: actions/github-script@v9 with: script: | - // pull_request_target opened/reopened/synchronize: gate on the PR author - // (auto-runs on maintainer-authored PRs; community PRs need the label path below). - // pull_request_target labeled (safe-to-review): gate on the labeler (sender) - // so a maintainer applying the label authorizes the run on a community PR. const isLabel = context.payload.action === 'labeled'; const user = isLabel ? context.payload.sender.login @@ -60,35 +70,25 @@ jobs: team_slug: 'agentcore-cli-devs', username: user, }); - console.log(`${reason} is a member of agentcore-cli-devs`); core.setOutput('authorized', 'true'); - } catch (teamError) { + } catch { try { const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner: context.repo.owner, repo: context.repo.repo, username: user, }); - const hasWriteAccess = ['write', 'admin'].includes(data.permission); - if (hasWriteAccess) { - console.log(`${reason} has write access (${data.permission})`); - core.setOutput('authorized', 'true'); - } else { - console.log(`${reason} does not have write access (${data.permission}) — skipping review`); - core.setOutput('authorized', 'false'); - } - } catch (collabError) { - console.log(`${reason} authorization check failed (${collabError.status}) — skipping review`); + core.setOutput('authorized', ['write', 'admin'].includes(data.permission) ? 'true' : 'false'); + } catch { core.setOutput('authorized', 'false'); } } - - name: Auto-authorize workflow_dispatch id: dispatch-auth if: github.event_name == 'workflow_dispatch' run: echo "authorized=true" >> "$GITHUB_OUTPUT" - security-review: + review: needs: authorize if: needs.authorize.outputs.authorized == 'true' runs-on: ubuntu-latest @@ -96,9 +96,6 @@ jobs: env: AWS_REGION: us-west-2 steps: - # Generate the GitHub App token first so every subsequent github-script step can - # use it. The default GITHUB_TOKEN is read-only on pull_request_target / - # pull_request_review events from forks, which makes label/comment writes 403. - name: Generate GitHub App token id: app-token uses: actions/create-github-app-token@v1 @@ -106,7 +103,7 @@ jobs: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - - name: Resolve PR number + - name: Resolve PR id: pr uses: actions/github-script@v9 env: @@ -114,10 +111,9 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} script: | - const num = - context.eventName === 'workflow_dispatch' - ? parseInt(process.env.PR_NUMBER_INPUT, 10) - : context.payload.pull_request.number; + const num = context.eventName === 'workflow_dispatch' + ? parseInt(process.env.PR_NUMBER_INPUT, 10) + : context.payload.pull_request.number; const { data: pr } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, @@ -127,7 +123,7 @@ jobs: core.setOutput('head_sha', pr.head.sha); core.setOutput('base_ref', pr.base.ref); - - name: Add claude-security-reviewing label + - name: Add reviewing label uses: actions/github-script@v9 env: PR_NUMBER: ${{ steps.pr.outputs.number }} @@ -148,7 +144,7 @@ jobs: repo: context.repo.repo, name: 'claude-security-reviewing', color: 'D73A4A', - description: 'Claude Code /security-review in progress', + description: 'Claude Code security review in progress', }); } } @@ -163,104 +159,229 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ steps.pr.outputs.head_sha }} - # The bundled /security-review skill runs `git diff origin/HEAD...` so we need - # the base branch locally too. fetch-depth: 0 grabs the full history. fetch-depth: 0 - - name: Prepare base ref for /security-review skill + - name: Compute diff + id: diff env: BASE_REF: ${{ steps.pr.outputs.base_ref }} run: | set -euo pipefail - # The bundled /security-review skill's SessionStart hook runs - # `git diff --name-only origin/HEAD...` as its first command. Two - # things have to be true for that to succeed: - # 1. origin/HEAD has to be a valid ref. actions/checkout doesn't - # set up the remote's symbolic HEAD, so we point it at the PR's - # base branch. - # 2. The PR head and origin/ have to share a merge base in - # the local clone. actions/checkout@v6 with `ref: ` - # fetches the head's history but on fork PRs may NOT fetch - # origin/ into the clone — leaving `git diff origin/HEAD...` - # to fail with "no merge base", which silently bombs the skill - # (num_turns=0, model never invoked) and would otherwise look - # like a clean review with zero findings. - # Fetching origin/ explicitly closes that gap. + # Compute the diff *before* claude-code-action shallows the repo. + # The action's restoreConfigFromBase() runs `git fetch --depth=1` + # against the base branch on startup, which strips history and + # would break any base-vs-head diff after that point. Doing it + # here means the model gets a frozen artifact that can't be + # invalidated by anything the action does later. git fetch --no-tags origin "+refs/heads/$BASE_REF:refs/remotes/origin/$BASE_REF" - git remote set-head origin "$BASE_REF" - git symbolic-ref refs/remotes/origin/HEAD - - # Sanity: ensure the merge base actually resolves before we hand off - # to the skill. If it doesn't, fail loudly here rather than letting - # the skill silently bail. - if ! git merge-base "origin/$BASE_REF" HEAD >/dev/null; then - echo "::error::No merge base between HEAD and origin/$BASE_REF — /security-review cannot compute a diff." - exit 1 - fi - echo "Merge base: $(git merge-base "origin/$BASE_REF" HEAD)" - echo "Files changed: $(git diff --name-only "origin/$BASE_REF...HEAD" | wc -l)" + git diff "origin/$BASE_REF...HEAD" > /tmp/pr.diff + BYTES=$(wc -c < /tmp/pr.diff) + FILES=$(git diff --name-only "origin/$BASE_REF...HEAD" | wc -l | tr -d ' ') + echo "bytes=$BYTES" >> "$GITHUB_OUTPUT" + echo "files=$FILES" >> "$GITHUB_OUTPUT" + echo "Diff: $BYTES bytes across $FILES files" + + - name: Build prompt + if: steps.diff.outputs.bytes != '0' + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/prompt" + cat > "$RUNNER_TEMP/prompt/prompt.md" <<'PROMPT_EOF' + You are performing a HIGH-CONFIDENCE security code review of a pull + request. The complete diff is at `/tmp/pr.diff` — read it first + using the Read tool. That file is the ground truth for what the + PR changes; do not run `git diff` or any other git commands. To + understand context — callers of a changed function, existing + sanitization patterns, the project's threat model — use Grep, + Glob, and Read against the repository working tree. Do not use + Bash. + + # OBJECTIVE + + Identify HIGH-CONFIDENCE security vulnerabilities newly introduced + by this PR that have real exploitation potential. This is NOT a + general code review. Focus ONLY on security implications added by + the PR. Do not comment on pre-existing issues. + + # CRITICAL INSTRUCTIONS + + 1. MINIMIZE FALSE POSITIVES: Only flag issues where you are >80% + confident of actual exploitability. + 2. AVOID NOISE: Skip theoretical issues, style concerns, or + low-impact findings. + 3. FOCUS ON IMPACT: Prioritize vulnerabilities that lead to + unauthorized access, data breach, or system compromise. + 4. DO NOT report any of: + - Denial of service / resource exhaustion / rate limiting + - Secrets at rest on disk (handled by other tooling) + - Memory consumption or CPU exhaustion + + # CATEGORIES TO EXAMINE + + **Input validation**: SQL injection, command injection, XXE, + template injection, NoSQL injection, path traversal. + **AuthN/AuthZ**: authentication bypass, privilege escalation, + session/JWT flaws, authorization-logic bypasses. + **Crypto & secrets**: hardcoded keys/passwords/tokens, weak + algorithms, improper key storage, weak randomness, certificate + validation bypass. + **Code execution**: deserialization RCE (pickle, YAML, etc.), + eval injection, XSS (reflected/stored/DOM) — only in unsafe paths + (see precedents). + **Data exposure**: sensitive logging, PII handling violations, + API leakage, debug-info exposure. + + A finding can still be HIGH severity if only exploitable from the + local network. + + # METHODOLOGY + + Phase 1 — Repository context: identify existing security + libraries/frameworks, sanitization patterns, the project's threat + model. Use search tools. + + Phase 2 — Comparative analysis: compare new changes against + established patterns; flag deviations and net-new attack surface. + + Phase 3 — Vulnerability assessment: for each modified file, + trace user input → sensitive operations, look for unsafe privilege + boundary crossings, identify injection points. + + # FALSE-POSITIVE FILTER (apply hard) + + Read the code (Read/Grep/Glob); do not run commands to reproduce + or write files. + + HARD EXCLUSIONS — drop any finding matching: + 1. DoS / resource exhaustion. + 2. Secrets/credentials on disk if otherwise secured. + 3. Rate limiting or service overload. + 4. Memory/CPU exhaustion. + 5. Missing input validation on non-security-critical fields. + 6. Input sanitization in GitHub Actions workflows unless clearly + triggerable via untrusted input. + 7. Lack of hardening; only flag concrete vulns. + 8. Theoretical race conditions or timing attacks. + 9. Outdated third-party libraries (handled separately). + 10. Memory-safety issues in memory-safe languages (Rust, Go, + JS/TS, Python). + 11. Files that are unit tests or test-only. + 12. Log spoofing — un-sanitized user input to logs is not a vuln. + 13. SSRF that only controls the path (host/protocol control is + required). + 14. User-controlled content in AI system prompts is not a vuln. + 15. Regex injection. + 16. Regex DoS. + 17. Insecure documentation (.md and similar). + 18. Lack of audit logs. + + PRECEDENTS: + 1. Plaintext-logging high-value secrets IS a vuln; logging URLs + is assumed safe. + 2. UUIDs are unguessable and need no validation. + 3. Env vars and CLI flags are trusted inputs. + 4. Resource leaks (memory, fd) are not vulns. + 5. Tabnabbing, XS-Leaks, prototype pollution, open redirects: + only with extremely high confidence. + 6. React / Angular: do not report XSS in components or .tsx files + unless using `dangerouslySetInnerHTML`, + `bypassSecurityTrustHtml`, or equivalents. + 7. GitHub Actions workflow vulns: only when a concrete attack + path through untrusted input exists. + 8. Missing AuthN/AuthZ in client-side code is not a vuln — + validation is the server's job. + 9. MEDIUM findings only when obvious and concrete. + 10. .ipynb notebook vulns: only with a concrete attack path + through untrusted input. + 11. Logging non-PII data is not a vuln. Only flag when the data + is secrets, passwords, or PII. + 12. Command injection in shell scripts: only when there is a + concrete attack path through untrusted input. + + For each surviving finding, score confidence 1–10: + - 1–3: low / likely noise — drop + - 4–6: medium — drop unless obvious and concrete + - 7–10: high — keep + + # PROCESS + + Run this in three steps, exactly: + + 1. Spawn a Task sub-agent to identify candidate vulnerabilities. + Pass the full instructions above (objective, categories, + methodology, hard exclusions, precedents). Have it return a + structured list of candidates with file/line/category/ + description/exploit/fix. + + 2. For EACH candidate from step 1, spawn an independent Task + sub-agent IN PARALLEL to adversarially verify it. Each + verifier gets the full FALSE-POSITIVE FILTER above and is + told to default to "drop" if uncertain. Each returns a + confidence score 1–10. + + 3. Drop any finding with confidence < 8. For every finding that + survives, call: + + mcp__github_inline_comment__create_inline_comment + + with `{ path, line, body }` pointing at the exact file and + line in the diff. The body should follow: + + **: ** + + **Recommendation:** + + Do NOT post a single summary comment listing all findings — + the workflow handles a top-level summary after this run + completes. If zero findings survive Phase 3, exit without + calling any tool. + + Begin. + PROMPT_EOF - - name: Configure AWS credentials (OIDC) + - name: Configure AWS credentials + if: steps.diff.outputs.bytes != '0' uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ secrets.BEDROCK_SECURITY_REVIEW_ROLE_ARN }} aws-region: us-west-2 - - name: Run Claude Code security review + - name: Run Claude Code id: review + if: steps.diff.outputs.bytes != '0' uses: anthropics/claude-code-action@v1 with: github_token: ${{ steps.app-token.outputs.token }} use_bedrock: 'true' - # The Claude Code SDK that ships with the action has /security-review bundled - # as a slash command. Invoking it directly lets the skill drive its own - # `git diff origin/HEAD...`, sub-task fan-out, and false-positive filtering - # without us re-implementing any of that. We append a short tail telling the - # action to use the inline-comment MCP tool for findings. - prompt: | - /security-review - - For each finding, call mcp__github_inline_comment__create_inline_comment with - { path, line, body } pointing at the exact file and line in the diff. Do NOT - post a single summary comment listing all findings — the workflow handles a - top-level summary after this run completes. If there are no findings, exit - without calling any tool. + prompt_file: ${{ runner.temp }}/prompt/prompt.md show_full_output: 'true' - # Allow-listing this MCP tool name is what tells the action to register the - # github_inline_comment MCP server. See anthropics/claude-code-action - # src/mcp/install-mcp-server.ts. + # Read/Grep/Glob let the model explore the repo for context + # (existing sanitization patterns, threat model, callers of a + # changed function). Task is needed for the parallel verifier + # sub-agents in Phase 2. The github_inline_comment MCP tool is + # the output channel; allow-listing it is also what tells the + # action to attach the inline-comment MCP server. Bash is + # intentionally NOT allowed: the prompt forbids running + # commands, and keeping Bash off the list makes the diff at + # /tmp/pr.diff the only ground truth (no `git diff` re-runs + # against a possibly-shallow clone). claude_args: >- - --model us.anthropic.claude-opus-4-7 --max-turns 30 --allowedTools - mcp__github_inline_comment__create_inline_comment + --model us.anthropic.claude-opus-4-7 --max-turns 60 --allowedTools "Read Grep Glob Task + mcp__github_inline_comment__create_inline_comment" - - name: Verify model actually ran + - name: Verify model ran productively id: model-ran - # The action exits 0 even when the model was never invoked (e.g. a - # SessionStart hook errored before the first turn). Treating that as - # success would let the workflow falsely report "no high-confidence - # findings" — that's exactly what happened on PR #1474, where the - # `/security-review` skill's first `git diff origin/HEAD...` hit a - # "no merge base" error, the SDK still returned `subtype: success` - # with `num_turns: 0` and zero tokens, and the buffer was empty. - # - # The action writes its full SDK transcript to - # ${RUNNER_TEMP}/claude-execution-output.json. We pull the final - # result envelope and require that the model actually took turns and - # spent tokens. If it didn't, mark the run as not-actually-completed - # so the summary comment uses the failure branch instead of pretending - # there were no findings. - if: steps.review.conclusion == 'success' || steps.review.conclusion == 'failure' + if: + steps.diff.outputs.bytes != '0' && (steps.review.conclusion == 'success' || steps.review.conclusion == + 'failure') env: - # The action exposes its full SDK transcript path as the - # `execution_file` step output (a JSON array of stream events; the - # final element is the result envelope). We fall back to the known - # path under RUNNER_TEMP if for some reason the output is missing. OUTPUT_JSON: ${{ steps.review.outputs.execution_file || format('{0}/claude-execution-output.json', runner.temp) }} run: | set -euo pipefail if [ ! -s "$OUTPUT_JSON" ]; then - echo "::warning::No claude-execution-output.json found at $OUTPUT_JSON; cannot verify the model ran." + echo "::warning::No execution transcript at $OUTPUT_JSON — cannot verify" echo "ran=unknown" >> "$GITHUB_OUTPUT" echo "num_turns=0" >> "$GITHUB_OUTPUT" exit 0 @@ -271,18 +392,20 @@ jobs: echo "num_turns=$NUM_TURNS, is_error=$IS_ERROR, output_tokens=$OUTPUT_TOKENS" echo "num_turns=$NUM_TURNS" >> "$GITHUB_OUTPUT" if [ "$IS_ERROR" = "true" ] || [ "$NUM_TURNS" = "0" ] || [ "$OUTPUT_TOKENS" = "0" ]; then - echo "::error::Claude Code SDK reported success but the model never ran productively (num_turns=$NUM_TURNS, output_tokens=$OUTPUT_TOKENS, is_error=$IS_ERROR). The /security-review skill likely bailed before analysis (e.g. SessionStart hook error). Refusing to report 'no findings'." + echo "::group::Last messages from SDK transcript" + jq -r '.[] | select(.type == "user" or .type == "system") | .message.content // .subtype' "$OUTPUT_JSON" | tail -40 + echo "::endgroup::" + echo "::error::Model did not run productively (turns=$NUM_TURNS, output_tokens=$OUTPUT_TOKENS, is_error=$IS_ERROR)" echo "ran=false" >> "$GITHUB_OUTPUT" exit 1 fi echo "ran=true" >> "$GITHUB_OUTPUT" - - name: Count buffered findings + - name: Count findings id: findings - # Only count if the review step actually ran (success or failure - both produce - # a meaningful buffer state). Skip on cancellation/skip so we don't lie about - # "no findings" when Bedrock was never invoked. - if: steps.review.conclusion == 'success' || steps.review.conclusion == 'failure' + if: + steps.diff.outputs.bytes != '0' && (steps.review.conclusion == 'success' || steps.review.conclusion == + 'failure') run: | set -euo pipefail BUFFER=/tmp/inline-comments-buffer.jsonl @@ -294,10 +417,7 @@ jobs: echo "count=$COUNT" >> "$GITHUB_OUTPUT" echo "Buffered findings: $COUNT" - - name: Post security review summary comment - # Always post some kind of summary so the PR shows the run happened, but branch on - # the review step's conclusion so a cancelled/skipped run doesn't get reported as - # "no findings". + - name: Post summary if: always() uses: actions/github-script@v9 env: @@ -306,6 +426,8 @@ jobs: REVIEW_CONCLUSION: ${{ steps.review.conclusion }} MODEL_RAN: ${{ steps.model-ran.outputs.ran }} NUM_TURNS: ${{ steps.model-ran.outputs.num_turns }} + DIFF_BYTES: ${{ steps.diff.outputs.bytes }} + DIFF_FILES: ${{ steps.diff.outputs.files }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} with: github-token: ${{ steps.app-token.outputs.token }} @@ -316,25 +438,21 @@ jobs: const modelRan = process.env.MODEL_RAN || 'unknown'; const numTurns = process.env.NUM_TURNS || '0'; const runUrl = process.env.RUN_URL; + const diffBytes = parseInt(process.env.DIFF_BYTES || '0', 10); let body; - if (modelRan !== 'true') { - // Two cases land here, both unsafe to report as "no findings": - // - 'false': SDK exited 0 but transcript shows the model never ran productively - // (e.g. /security-review's SessionStart hook errored before the first turn). - // - 'unknown': claude-execution-output.json was missing, so we couldn't verify - // the model ran at all. Treat as not-verified rather than silently green. - body = `**Claude Security Review:** the review did not actually analyze this PR (model took ${numTurns} turn${numTurns === '1' ? '' : 's'} — the skill likely failed during setup). See the [run](${runUrl}) for details; a later push or re-run is needed for a real review.`; + if (diffBytes === 0) { + body = `**Claude Security Review:** PR has an empty diff against base — nothing to review. ([run](${runUrl}))`; + } else if (modelRan !== 'true') { + body = `**Claude Security Review:** the review did not analyze this PR (model took ${numTurns} turn${numTurns === '1' ? '' : 's'}). See the [run](${runUrl}) for details; a later push or re-run is needed.`; } else if (conclusion === 'success') { - body = - count > 0 - ? `**Claude Security Review:** posted ${count} inline finding${count === 1 ? '' : 's'} on this PR. ([run](${runUrl}))` - : `**Claude Security Review:** no high-confidence findings. ([run](${runUrl}))`; + body = count > 0 + ? `**Claude Security Review:** posted ${count} inline finding${count === 1 ? '' : 's'} on this PR. ([run](${runUrl}))` + : `**Claude Security Review:** no high-confidence findings. ([run](${runUrl}))`; } else if (conclusion === 'failure') { body = `**Claude Security Review:** the review run failed before completing. See the [run](${runUrl}) for details.`; } else { - // cancelled / skipped — analysis didn't run, do NOT claim "no findings" - body = `**Claude Security Review:** the review run was ${conclusion} before the analysis could complete (likely superseded or interrupted). See the [run](${runUrl}); a later run on this PR will replace this status.`; + body = `**Claude Security Review:** the review run was ${conclusion} before analysis could complete. See the [run](${runUrl}); a later run on this PR will replace this status.`; } await github.rest.issues.createComment({ @@ -344,7 +462,7 @@ jobs: body, }); - - name: Remove claude-security-reviewing label + - name: Remove reviewing label if: always() uses: actions/github-script@v9 env: From 98528a6d7bee6fbbd2a994e7453ab6cef533bc9a Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:31:25 -0400 Subject: [PATCH 25/27] fix(ci): pass security-review prompt inline to claude-code-action (#298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous version wrote the prompt to a file and passed `prompt_file:` to anthropics/claude-code-action@v1, but the action only accepts `prompt:` (a string) — `prompt_file` is silently ignored, so the action sees no prompt, fails its internal trigger detection ("No trigger found"), and skips before the model is invoked. Build the prompt in a step (existing), then load it into GITHUB_ENV as PROMPT_BODY, then pass `prompt: ${{ env.PROMPT_BODY }}` to the action. Standard multi-line env-var idiom with a randomized heredoc sentinel. --- .github/workflows/pr-security-review.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-security-review.yml b/.github/workflows/pr-security-review.yml index f94d91572..ea11529e1 100644 --- a/.github/workflows/pr-security-review.yml +++ b/.github/workflows/pr-security-review.yml @@ -347,6 +347,26 @@ jobs: role-to-assume: ${{ secrets.BEDROCK_SECURITY_REVIEW_ROLE_ARN }} aws-region: us-west-2 + - name: Load prompt into env + id: load-prompt + if: steps.diff.outputs.bytes != '0' + # The action only accepts `prompt:` (a string), not a file path — + # passing prompt_file silently no-ops, leaves the action with no + # trigger, and skips the run with "No trigger found". Read the + # built prompt into an environment variable so we can pass it + # inline below. Using GITHUB_ENV with a randomized heredoc + # sentinel is the standard Actions idiom for multi-line values. + env: + PROMPT_FILE: ${{ runner.temp }}/prompt/prompt.md + run: | + set -euo pipefail + DELIM="EOF_$(uuidgen)" + { + echo "PROMPT_BODY<<$DELIM" + cat "$PROMPT_FILE" + echo "$DELIM" + } >> "$GITHUB_ENV" + - name: Run Claude Code id: review if: steps.diff.outputs.bytes != '0' @@ -354,7 +374,7 @@ jobs: with: github_token: ${{ steps.app-token.outputs.token }} use_bedrock: 'true' - prompt_file: ${{ runner.temp }}/prompt/prompt.md + prompt: ${{ env.PROMPT_BODY }} show_full_output: 'true' # Read/Grep/Glob let the model explore the repo for context # (existing sanitization patterns, threat model, callers of a From 9687e1556398c2f71a70ab326c8211ca4df0c0ab Mon Sep 17 00:00:00 2001 From: Hweinstock <42325418+Hweinstock@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:37:58 -0400 Subject: [PATCH 26/27] release: nys summit release (#282) --- .github/workflows/build-and-test.yml | 4 +- .github/workflows/codeql.yml | 4 +- .github/workflows/e2e-tests-full.yml | 31 +- .github/workflows/e2e-tests.yml | 2 +- .github/workflows/lint.yml | 4 +- .github/workflows/pr-ai-review.yml | 5 +- .github/workflows/pr-size.yml | 2 +- .github/workflows/pr-tarball.yml | 2 +- .github/workflows/pr-title.yml | 2 +- README.md | 48 +- docs/ab-tests.md | 154 +++ docs/batch-evaluation.md | 45 +- docs/commands.md | 177 +-- docs/config-bundles.md | 14 +- docs/connector-config-templates/README.md | 71 ++ .../confluence.json | 16 + .../google-drive.json | 12 + docs/connector-config-templates/onedrive.json | 16 + .../sharepoint.json | 14 + .../web-crawler.json | 16 + docs/knowledge-bases.md | 295 +++++ docs/recommendations.md | 30 +- e2e-tests/README.md | 1 - e2e-tests/ab-test-config-bundle.test.ts | 209 ---- e2e-tests/ab-test-target-based.test.ts | 317 ----- e2e-tests/archive-lifecycle.test.ts | 28 +- e2e-tests/config-bundle-eval-rec.test.ts | 22 +- e2e-tests/global-setup.ts | 19 + e2e-tests/guardrail-block.test.ts | 233 ++++ e2e-tests/utils/recommendation-cleanup.ts | 62 + .../add-remove-ab-test-target-based.test.ts | 461 -------- integ-tests/add-remove-ab-test.test.ts | 183 --- integ-tests/add-remove-config-bundle.test.ts | 4 +- integ-tests/add-remove-gateway.test.ts | 24 +- .../add-remove-online-insights.test.ts | 329 ++++++ integ-tests/promote-ab-test.test.ts | 161 +++ integ-tests/recommendation.test.ts | 151 --- integ-tests/run-ab-test.test.ts | 116 ++ integ-tests/run-insights.test.ts | 207 ++++ .../run-recommendation-from-insights.test.ts | 72 ++ npm-shrinkwrap.json | 517 +++----- package.json | 14 +- schemas/agentcore.schema.v1.json | 201 +--- scripts/run-e2e-local.sh | 11 + .../assets.snapshot.test.ts.snap | 989 ++++++++++++++-- src/assets/agents/AGENTS.md | 14 + src/assets/cdk/bin/cdk.ts | 62 +- src/assets/cdk/lib/cdk-stack.ts | 33 +- src/assets/cdk/test/cdk.test.ts | 2 +- src/assets/container/python/Dockerfile | 4 + .../strands/capabilities/memory/session.py | 5 +- .../strands/capabilities/memory/session.py | 5 +- src/assets/python/http/strands/base/main.py | 465 +++++++- .../http/strands/base/mcp_client/client.py | 44 +- .../python/http/strands/base/model/load.py | 6 +- .../python/http/strands/base/pyproject.toml | 5 +- .../http/strands/base/skills/fetcher.py | 279 +++++ .../hooks/execution_limits.py | 54 + .../strands/capabilities/memory/session.py | 5 +- .../aws/__tests__/agentcore-ab-tests.test.ts | 6 - .../agentcore-batch-evaluation.test.ts | 357 ++++++ .../agentcore-config-bundles.test.ts | 84 ++ .../aws/__tests__/agentcore-harness.test.ts | 141 +-- .../__tests__/agentcore-http-gateways.test.ts | 235 ---- .../agentcore-recommendation.test.ts | 76 ++ src/cli/aws/__tests__/agentcore.test.ts | 18 + src/cli/aws/__tests__/bedrock-agent.test.ts | 262 +++++ src/cli/aws/agentcore-ab-tests.ts | 16 +- src/cli/aws/agentcore-batch-evaluation.ts | 52 +- src/cli/aws/agentcore-config-bundles.ts | 1 + src/cli/aws/agentcore-control.ts | 38 +- src/cli/aws/agentcore-harness.ts | 190 +-- src/cli/aws/agentcore-http-gateways.ts | 512 -------- src/cli/aws/agentcore-payments.ts | 20 +- src/cli/aws/agentcore-recommendation.ts | 14 +- src/cli/aws/agentcore.ts | 12 +- src/cli/aws/bedrock-agent.ts | 182 +++ src/cli/aws/index.ts | 13 +- src/cli/cdk/toolkit-lib/wrapper.ts | 12 +- src/cli/cli.ts | 22 +- .../__tests__/outputs-config-bundles.test.ts | 82 ++ .../__tests__/outputs-extended.test.ts | 54 + .../__tests__/outputs-harness.test.ts | 138 +++ .../cloudformation/__tests__/outputs.test.ts | 75 +- .../__tests__/parse-kb-outputs.test.ts | 69 ++ src/cli/cloudformation/outputs.ts | 321 ++++- src/cli/commands/abtest/command.ts | 199 ---- src/cli/commands/abtest/index.ts | 1 - .../add/__tests__/add-gateway-target.test.ts | 7 +- .../add/__tests__/add-knowledge-base.test.ts | 106 ++ .../add/__tests__/auth-options.test.ts | 160 +++ .../harness-privatelink-guard.test.ts | 191 +++ .../add/__tests__/skill-action.test.ts | 209 ++++ .../commands/add/__tests__/validate.test.ts | 148 +++ src/cli/commands/add/auth-options.ts | 142 ++- src/cli/commands/add/skill-action.ts | 140 +++ src/cli/commands/add/skill-command.ts | 78 ++ src/cli/commands/add/tool-action.ts | 40 + src/cli/commands/add/tool-command.ts | 7 + src/cli/commands/add/types.ts | 44 +- src/cli/commands/add/validate.ts | 350 +++++- .../archive/__tests__/command.test.ts | 347 +----- src/cli/commands/archive/command.tsx | 106 +- .../commands/batch-evaluations/command.tsx | 57 + src/cli/commands/batch-evaluations/index.ts | 1 + src/cli/commands/config-bundle/command.tsx | 37 +- .../create/__tests__/harness-validate.test.ts | 24 + .../create/__tests__/validate.test.ts | 26 + src/cli/commands/create/command.tsx | 41 +- src/cli/commands/create/harness-action.ts | 4 + src/cli/commands/create/harness-validate.ts | 18 +- src/cli/commands/create/types.ts | 2 + src/cli/commands/create/validate.ts | 13 +- .../__tests__/harness-version-drift.test.ts | 35 + src/cli/commands/deploy/actions.ts | 410 +++---- src/cli/commands/deploy/command.tsx | 14 + src/cli/commands/dev/command.tsx | 2 +- .../commands/exec/__tests__/command.test.ts | 61 + src/cli/commands/exec/command.tsx | 12 +- .../export/__tests__/harness-action.test.ts | 42 + .../export/__tests__/harness-mapper.test.ts | 1038 +++++++++++++++++ src/cli/commands/export/constants.ts | 19 + src/cli/commands/export/harness-action.ts | 384 ++++++ src/cli/commands/export/harness-mapper.ts | 956 +++++++++++++++ src/cli/commands/export/harness-resolver.ts | 95 ++ src/cli/commands/export/index.ts | 83 ++ src/cli/commands/export/types.ts | 78 ++ .../__tests__/import-gateway-flow.test.ts | 15 + .../__tests__/import-gateway-spec.test.ts | 47 +- .../__tests__/import-gateway-targets.test.ts | 362 ------ .../import/__tests__/import-gateway.test.ts | 42 +- .../import/__tests__/import-no-deploy.test.ts | 14 + .../__tests__/import-online-eval.test.ts | 4 +- .../__tests__/import-runtime-handler.test.ts | 1 + .../import/__tests__/jwt-authorizer.test.ts | 5 +- src/cli/commands/import/constants.ts | 2 + src/cli/commands/import/import-gateway.ts | 214 ++-- .../invoke/__tests__/action-gateway.test.ts | 98 ++ .../__tests__/build-harness-base-opts.test.ts | 56 + .../commands/invoke/__tests__/command.test.ts | 25 + src/cli/commands/invoke/action.ts | 224 +++- src/cli/commands/invoke/command.tsx | 74 +- src/cli/commands/invoke/types.ts | 12 +- .../commands/logs/__tests__/action.test.ts | 8 +- .../commands/pause/__tests__/promote.test.ts | 59 - src/cli/commands/pause/command.tsx | 283 ++--- src/cli/commands/pause/index.ts | 2 +- src/cli/commands/pause/promote-utils.ts | 28 - src/cli/commands/promote/command.tsx | 34 + src/cli/commands/promote/index.ts | 1 + src/cli/commands/recommendations/command.tsx | 78 +- .../__tests__/remove-gateway-target.test.ts | 7 +- .../remove/__tests__/remove-gateway.test.ts | 8 +- .../remove/__tests__/skill-command.test.ts | 147 +++ src/cli/commands/remove/command.tsx | 2 +- src/cli/commands/remove/skill-command.ts | 138 +++ src/cli/commands/remove/types.ts | 3 +- src/cli/commands/run/command.tsx | 843 +++++++++---- .../commands/status/__tests__/action.test.ts | 451 ++++++- .../__tests__/format-knowledge-base.test.ts | 117 ++ src/cli/commands/status/action.ts | 254 +++- src/cli/commands/status/command.tsx | 47 +- .../commands/status/format-knowledge-base.ts | 94 ++ src/cli/commands/stop/command.tsx | 67 +- src/cli/commands/stop/index.ts | 2 +- src/cli/commands/validate/action.ts | 12 + src/cli/commands/view/JobDetailScreen.tsx | 109 ++ src/cli/commands/view/command.tsx | 129 ++ src/cli/commands/view/index.ts | 1 + src/cli/constants.ts | 9 +- .../__tests__/checks-extended.test.ts | 20 +- src/cli/feature-flags.ts | 2 + src/cli/logging/remove-logger.ts | 3 +- .../ab-test/__tests__/promote.test.ts | 270 ----- src/cli/operations/ab-test/promote.ts | 124 -- .../agent/generate/write-agent-to-project.ts | 2 +- .../archive/__tests__/archive-storage.test.ts | 130 --- src/cli/operations/archive/archive-storage.ts | 43 - src/cli/operations/archive/index.ts | 1 - .../__tests__/managed-memory-notice.test.ts | 78 ++ .../__tests__/post-deploy-ab-tests.test.ts | 660 ----------- .../post-deploy-config-bundles.test.ts | 654 ----------- .../post-deploy-http-gateways.test.ts | 471 -------- .../post-deploy-knowledge-bases.test.ts | 165 +++ .../deploy/__tests__/preflight.test.ts | 57 +- .../__tests__/harness-deployer.test.ts | 466 -------- .../__tests__/harness-mapper.test.ts | 753 ------------ .../imperative/deployers/harness-deployer.ts | 391 ------- .../imperative/deployers/harness-mapper.ts | 433 ------- .../deploy/imperative/deployers/index.ts | 2 - src/cli/operations/deploy/imperative/index.ts | 18 - .../operations/deploy/imperative/manager.ts | 110 -- src/cli/operations/deploy/imperative/types.ts | 32 - src/cli/operations/deploy/index.ts | 19 +- .../deploy/managed-memory-notice.ts | 47 + .../operations/deploy/post-deploy-ab-tests.ts | 721 ------------ .../deploy/post-deploy-config-bundles.ts | 348 ------ .../deploy/post-deploy-http-gateways.ts | 652 ----------- .../deploy/post-deploy-knowledge-bases.ts | 134 +++ src/cli/operations/deploy/preflight.ts | 93 +- src/cli/operations/deploy/teardown.ts | 61 +- .../operations/dev/__tests__/config.test.ts | 42 +- .../dev/__tests__/sse-transform.test.ts | 111 ++ src/cli/operations/dev/invoke.ts | 14 +- src/cli/operations/dev/sse-transform.ts | 45 + .../__tests__/resolve-ui-dist-dir.test.ts | 3 +- src/cli/operations/dev/web-ui/api-types.ts | 20 +- .../dev/web-ui/handlers/invocations.ts | 12 +- .../dev/web-ui/handlers/resources.ts | 27 + src/cli/operations/eval/batch-eval-storage.ts | 91 -- .../operations/eval/run-batch-evaluation.ts | 428 ------- .../__tests__/fetch-gateway-token.test.ts | 1 + .../fetch-access/fetch-gateway-token.ts | 3 +- .../operations/fetch-access/list-gateways.ts | 12 +- .../harness/__tests__/orphan.test.ts | 92 ++ src/cli/operations/harness/orphan.ts | 76 ++ src/cli/operations/harness/skill-utils.ts | 37 + .../operations/ingest/__tests__/index.test.ts | 296 +++++ src/cli/operations/ingest/index.ts | 215 ++++ src/cli/operations/insights/index.ts | 10 + .../operations/insights/insights-storage.ts | 58 + src/cli/operations/insights/run-insights.ts | 231 ++++ src/cli/operations/insights/types.ts | 46 + .../__tests__/resolve-agent-context.test.ts | 1 + .../ab-test/__tests__/build-options.test.ts | 190 +++ .../jobs/ab-test/__tests__/format.test.ts | 47 + .../jobs/ab-test/__tests__/promote.test.ts | 434 +++++++ .../operations/jobs/ab-test/build-options.ts | 158 +++ src/cli/operations/jobs/ab-test/format.ts | 85 ++ src/cli/operations/jobs/ab-test/handler.ts | 466 ++++++++ src/cli/operations/jobs/ab-test/promote.ts | 207 ++++ src/cli/operations/jobs/ab-test/resolve.ts | 246 ++++ .../jobs/batch-evaluation/build-source.ts | 73 ++ .../jobs/batch-evaluation/dataset-phase1.ts | 109 ++ .../jobs/batch-evaluation/format.ts | 49 + .../jobs/batch-evaluation/handler.ts | 229 ++++ src/cli/operations/jobs/index.ts | 46 + .../jobs/insights/__tests__/handler.test.ts | 24 + src/cli/operations/jobs/insights/format.ts | 75 ++ src/cli/operations/jobs/insights/handler.ts | 212 ++++ .../__tests__/apply-to-bundle.test.ts | 6 +- .../__tests__/auto-name.test.ts | 39 + .../__tests__/fetch-session-spans.test.ts | 2 +- .../__tests__/input-validation.test.ts | 155 +++ .../recommendation/__tests__/refresh.test.ts | 102 ++ .../recommendation/apply-to-bundle.ts | 8 +- .../jobs/recommendation/build-config.ts | 310 +++++ .../recommendation/fetch-session-spans.ts | 4 +- .../operations/jobs/recommendation/format.ts | 63 + .../operations/jobs/recommendation/handler.ts | 336 ++++++ .../jobs/shared/__tests__/constants.test.ts | 93 ++ .../jobs/shared/__tests__/engine.test.ts | 248 ++++ .../jobs/shared/__tests__/region.test.ts | 41 + .../jobs/shared/__tests__/storage.test.ts | 102 ++ src/cli/operations/jobs/shared/constants.ts | 99 ++ src/cli/operations/jobs/shared/engine.ts | 231 ++++ src/cli/operations/jobs/shared/format.ts | 13 + src/cli/operations/jobs/shared/region.ts | 36 + .../jobs/shared/resolve-agent-state.ts | 21 + src/cli/operations/jobs/shared/storage.ts | 114 ++ src/cli/operations/jobs/shared/types.ts | 456 ++++++++ src/cli/operations/jobs/shared/wait.ts | 38 + .../__tests__/agentic-retrieve-upsert.test.ts | 59 + .../__tests__/connector-config.test.ts | 119 ++ .../__tests__/hydrate-data-sources.test.ts | 110 ++ .../__tests__/templates.test.ts | 18 + .../knowledge-base/agentic-retrieve-upsert.ts | 42 + .../knowledge-base/connector-config.ts | 116 ++ .../knowledge-base/hydrate-data-sources.ts | 79 ++ .../mcp/__tests__/create-mcp-utils.test.ts | 4 + .../__tests__/recommendation-storage.test.ts | 136 --- .../__tests__/run-recommendation.test.ts | 720 ------------ .../operations/recommendation/constants.ts | 11 - src/cli/operations/recommendation/index.ts | 18 - .../recommendation/recommendation-storage.ts | 84 -- .../recommendation/run-recommendation.ts | 623 ---------- src/cli/operations/recommendation/types.ts | 65 -- .../remove/__tests__/remove-agent-ops.test.ts | 1 + .../__tests__/remove-identity-ops.test.ts | 1 + src/cli/primitives/ABTestPrimitive.ts | 732 ------------ src/cli/primitives/AgentPrimitive.tsx | 5 +- src/cli/primitives/ConfigBundlePrimitive.ts | 13 +- src/cli/primitives/EvaluatorPrimitive.ts | 4 +- src/cli/primitives/GatewayPrimitive.ts | 64 +- src/cli/primitives/GatewayTargetPrimitive.ts | 755 +++++++++++- src/cli/primitives/HarnessPrimitive.ts | 848 +++++++++++++- src/cli/primitives/KnowledgeBasePrimitive.ts | 688 +++++++++++ .../primitives/OnlineEvalConfigPrimitive.ts | 55 +- src/cli/primitives/OnlineInsightsPrimitive.ts | 270 +++++ .../primitives/PaymentConnectorPrimitive.ts | 4 +- src/cli/primitives/PaymentManagerPrimitive.ts | 4 +- src/cli/primitives/PolicyEnginePrimitive.ts | 38 +- src/cli/primitives/PolicyPrimitive.ts | 165 ++- .../__tests__/ABTestPrimitive.test.ts | 289 ----- .../__tests__/GatewayPrimitive.test.ts | 2 +- .../__tests__/GatewayTargetPrimitive.test.ts | 412 +++++++ .../__tests__/HarnessPrimitive.remove.test.ts | 271 +++++ .../__tests__/KnowledgeBasePrimitive.test.ts | 743 ++++++++++++ .../__tests__/OnlineInsightsPrimitive.test.ts | 36 + .../PaymentConnectorPrimitive.test.ts | 2 + .../__tests__/PaymentManagerPrimitive.test.ts | 2 + .../__tests__/PolicyPrimitive.test.ts | 111 ++ .../primitives/__tests__/auth-utils.test.ts | 2 +- .../__tests__/wirePaymentCapability.test.ts | 2 + src/cli/primitives/auth-utils.ts | 15 +- src/cli/primitives/constants.ts | 6 + src/cli/primitives/index.ts | 2 - src/cli/primitives/registry.ts | 9 +- src/cli/project.ts | 2 +- src/cli/telemetry/schemas/command-run.ts | 61 +- src/cli/telemetry/schemas/common-shapes.ts | 27 +- src/cli/templates/BaseRenderer.ts | 13 +- .../templates/__tests__/BaseRenderer.test.ts | 2 +- src/cli/templates/render.ts | 28 + src/cli/templates/types.ts | 57 + src/cli/tui/App.tsx | 112 +- .../__tests__/app-command-coverage.test.ts | 54 + src/cli/tui/components/DeployStatus.tsx | 10 +- src/cli/tui/components/ResourceGraph.tsx | 58 +- src/cli/tui/components/TextInput.tsx | 6 +- .../__tests__/DeployStatus.test.tsx | 15 + .../__tests__/ResourceGraph.test.tsx | 1 + .../components/__tests__/TextInput.test.tsx | 8 +- .../jwt-config/DomainOverridesManager.tsx | 219 ++++ .../components/jwt-config/JwtConfigInput.tsx | 167 ++- src/cli/tui/components/jwt-config/index.ts | 1 + src/cli/tui/components/jwt-config/types.ts | 46 + .../components/jwt-config/useJwtConfigFlow.ts | 160 ++- src/cli/tui/copy.ts | 59 +- .../tui/hooks/__tests__/useDevDeploy.test.tsx | 38 +- .../__tests__/usePanelNavigation.test.tsx | 2 +- .../tui/hooks/__tests__/useRemove.test.tsx | 9 + src/cli/tui/hooks/index.ts | 8 +- src/cli/tui/hooks/useCreateABTest.ts | 93 -- src/cli/tui/hooks/useCreateMcp.ts | 78 ++ src/cli/tui/hooks/useCreateOnlineEval.ts | 4 + src/cli/tui/hooks/useDevServer.ts | 2 +- src/cli/tui/hooks/useRemove.ts | 41 +- src/cli/tui/render.ts | 9 +- .../screens/ab-test/ABTestDetailScreen.tsx | 637 ---------- .../screens/ab-test/ABTestPickerScreen.tsx | 90 -- src/cli/tui/screens/ab-test/AddABTestFlow.tsx | 281 ----- .../tui/screens/ab-test/AddABTestScreen.tsx | 914 --------------- .../screens/ab-test/RemoveABTestScreen.tsx | 26 - .../ab-test/TargetBasedABTestScreen.tsx | 712 ----------- .../tui/screens/ab-test/VariantConfigForm.tsx | 268 ----- .../__tests__/useAddABTestWizard.test.tsx | 286 ----- .../__tests__/useTargetBasedWizard.test.tsx | 319 ----- src/cli/tui/screens/ab-test/index.ts | 4 - src/cli/tui/screens/ab-test/types.ts | 89 -- .../tui/screens/ab-test/useAddABTestWizard.ts | 324 ----- .../screens/ab-test/useTargetBasedWizard.ts | 188 --- src/cli/tui/screens/add/AddFlow.tsx | 78 +- src/cli/tui/screens/add/AddScreen.tsx | 36 +- .../screens/add/__tests__/AddScreen.test.tsx | 22 +- src/cli/tui/screens/agent/AddAgentScreen.tsx | 5 +- .../ConfigBundleHubScreen.tsx | 14 +- .../config-bundle-hub/useConfigBundleHub.ts | 17 +- .../config-bundle/AddConfigBundleFlow.tsx | 4 +- .../config-bundle/AddConfigBundleScreen.tsx | 24 + .../useAddConfigBundleWizard.test.tsx | 167 +++ .../tui/screens/config-bundle/constants.ts | 15 + src/cli/tui/screens/config-bundle/types.ts | 5 +- .../config-bundle/useAddConfigBundleWizard.ts | 40 +- src/cli/tui/screens/create/CreateScreen.tsx | 2 +- src/cli/tui/screens/create/useCreateFlow.ts | 1 + src/cli/tui/screens/deploy/DeployScreen.tsx | 9 + src/cli/tui/screens/deploy/useDeployFlow.ts | 625 +++++----- src/cli/tui/screens/eval/EvalHubScreen.tsx | 23 +- .../tui/screens/export/ExportHarnessFlow.tsx | 196 ++++ .../screens/export/ExportHarnessScreen.tsx | 137 +++ src/cli/tui/screens/export/index.ts | 1 + src/cli/tui/screens/export/types.ts | 14 + .../screens/export/useExportHarnessWizard.ts | 63 + .../screens/generate/__tests__/types.test.ts | 45 + src/cli/tui/screens/generate/types.ts | 17 +- .../tui/screens/generate/useGenerateWizard.ts | 5 +- .../tui/screens/harness/AddHarnessFlow.tsx | 62 +- .../tui/screens/harness/AddHarnessScreen.tsx | 711 ++++++++++- src/cli/tui/screens/harness/types.ts | 160 ++- .../screens/harness/useAddHarnessWizard.ts | 446 ++++++- src/cli/tui/screens/import/ArnInputScreen.tsx | 3 +- .../insights-jobs/InsightsJobsScreen.tsx | 383 ++++++ src/cli/tui/screens/insights-jobs/index.ts | 1 + .../screens/job-detail/ABTestDetailView.tsx | 235 ++++ .../job-detail/BatchEvalDetailView.tsx | 157 +++ .../job-detail/RecommendationDetailView.tsx | 132 +++ src/cli/tui/screens/job-detail/helpers.ts | 32 + src/cli/tui/screens/job-detail/index.ts | 4 + .../knowledge-base/AddKnowledgeBaseFlow.tsx | 196 ++++ .../knowledge-base/AddKnowledgeBaseScreen.tsx | 506 ++++++++ .../__tests__/AddKnowledgeBaseFlow.test.tsx | 134 +++ .../__tests__/AddKnowledgeBaseScreen.test.tsx | 238 ++++ .../__tests__/groupDataSources.test.ts | 49 + .../__tests__/inline-connector-config.test.ts | 97 ++ .../knowledge-base/groupDataSources.ts | 49 + src/cli/tui/screens/knowledge-base/index.ts | 2 + .../knowledge-base/inline-connector-config.ts | Bin 0 -> 3191 bytes src/cli/tui/screens/knowledge-base/types.ts | 43 + .../logs/__tests__/LogsScreen.test.tsx | 9 + .../tui/screens/mcp/AddGatewayTargetFlow.tsx | 79 +- .../screens/mcp/AddGatewayTargetScreen.tsx | 419 ++++++- .../mcp/__tests__/discriminated-union.test.ts | 31 + .../tui/screens/mcp/__tests__/types.test.ts | 14 +- src/cli/tui/screens/mcp/types.ts | 152 ++- .../screens/mcp/useAddGatewayTargetWizard.ts | 203 +++- .../screens/online-eval/AddOnlineEvalFlow.tsx | 8 - .../online-eval/AddOnlineEvalScreen.tsx | 149 ++- .../online-eval/OnlineEvalDashboard.tsx | 2 +- src/cli/tui/screens/online-eval/types.ts | 10 + .../online-eval/useAddOnlineEvalWizard.ts | 71 +- .../online-insights/AddOnlineInsightsFlow.tsx | 145 +++ .../AddOnlineInsightsScreen.tsx | 216 ++++ src/cli/tui/screens/online-insights/index.ts | 2 + src/cli/tui/screens/online-insights/types.ts | 49 + .../useAddOnlineInsightsWizard.ts | 106 ++ src/cli/tui/screens/policy/AddPolicyFlow.tsx | 84 +- .../tui/screens/policy/AddPolicyScreen.tsx | 346 +++++- .../policy/__tests__/synthesize-cedar.test.ts | 128 ++ .../useAddPolicyWizard.render.test.tsx | 76 ++ .../tui/screens/policy/synthesize-cedar.ts | 58 + src/cli/tui/screens/policy/types.ts | 174 ++- .../tui/screens/policy/useAddPolicyWizard.ts | 135 ++- .../recommendation/RecommendationFlow.tsx | 302 +---- .../RecommendationHistoryScreen.tsx | 194 +-- .../recommendation/RecommendationScreen.tsx | 24 + .../RecommendationsHubScreen.tsx | 6 +- src/cli/tui/screens/recommendation/types.ts | 10 +- .../recommendation/useRecommendationWizard.ts | 17 +- src/cli/tui/screens/remove/RemoveFlow.tsx | 274 +++-- .../remove/RemoveKnowledgeBaseScreen.tsx | 26 + src/cli/tui/screens/remove/RemoveScreen.tsx | 38 +- .../remove/__tests__/RemoveScreen.test.tsx | 60 +- src/cli/tui/screens/remove/index.ts | 1 + .../run-ab-test/ABTestJobsHistoryScreen.tsx | 196 ++++ .../tui/screens/run-ab-test/RunABTestFlow.tsx | 805 +++++++++++++ src/cli/tui/screens/run-ab-test/index.ts | 2 + src/cli/tui/screens/run-ab-test/types.ts | 37 + .../run-eval/BatchEvalHistoryScreen.tsx | 281 ++--- .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 686 ++++++----- .../tui/screens/run-eval/RunIngestFlow.tsx | 669 +++++++++++ src/cli/tui/screens/run-eval/RunScreen.tsx | 31 +- src/cli/tui/screens/run-eval/index.ts | 1 + .../screens/run-insights/RunInsightsFlow.tsx | 148 +++ .../run-insights/RunInsightsScreen.tsx | 221 ++++ src/cli/tui/screens/run-insights/index.ts | 2 + src/cli/tui/screens/run-insights/types.ts | 81 ++ .../run-insights/useRunInsightsWizard.ts | 151 +++ .../tui/screens/view/ViewTypePickerScreen.tsx | 40 + src/cli/tui/screens/view/index.ts | 1 + .../screens/web-search/AddWebSearchFlow.tsx | 88 ++ .../screens/web-search/AddWebSearchScreen.tsx | 158 +++ src/cli/tui/screens/web-search/index.ts | 2 + src/cli/tui/screens/web-search/types.ts | 10 + src/lib/errors/types.ts | 27 + src/lib/schemas/io/config-io.ts | 1 - src/schema/__tests__/constants.test.ts | 51 + src/schema/constants.ts | 28 + src/schema/llm-compacted/agentcore.ts | 36 +- src/schema/llm-compacted/mcp.ts | 31 +- .../__tests__/agentcore-project.test.ts | 222 ++++ .../schemas/__tests__/deployed-state.test.ts | 69 ++ src/schema/schemas/__tests__/mcp-defs.test.ts | 5 +- src/schema/schemas/__tests__/mcp.test.ts | 305 +++++ .../__tests__/online-eval-config.test.ts | 69 ++ .../__tests__/private-endpoint.test.ts | 232 ++++ src/schema/schemas/agentcore-project.ts | 141 ++- src/schema/schemas/auth.ts | 148 ++- src/schema/schemas/deployed-state.ts | 65 +- src/schema/schemas/mcp-defs.ts | 7 +- src/schema/schemas/mcp.ts | 508 +++++++- .../primitives/__tests__/ab-test.test.ts | 7 - .../primitives/__tests__/evaluator.test.ts | 43 + .../primitives/__tests__/harness.test.ts | 502 +++++++- .../primitives/__tests__/http-gateway.test.ts | 82 -- .../__tests__/knowledge-base.test.ts | 203 ++++ src/schema/schemas/primitives/ab-test.ts | 2 - src/schema/schemas/primitives/harness.ts | 350 +++++- src/schema/schemas/primitives/http-gateway.ts | 41 - src/schema/schemas/primitives/index.ts | 13 +- .../schemas/primitives/knowledge-base.ts | 122 ++ .../schemas/primitives/online-eval-config.ts | 74 +- src/schema/schemas/primitives/policy.ts | 8 + 483 files changed, 40842 insertions(+), 21399 deletions(-) create mode 100644 docs/ab-tests.md create mode 100644 docs/connector-config-templates/README.md create mode 100644 docs/connector-config-templates/confluence.json create mode 100644 docs/connector-config-templates/google-drive.json create mode 100644 docs/connector-config-templates/onedrive.json create mode 100644 docs/connector-config-templates/sharepoint.json create mode 100644 docs/connector-config-templates/web-crawler.json create mode 100644 docs/knowledge-bases.md delete mode 100644 e2e-tests/ab-test-config-bundle.test.ts delete mode 100644 e2e-tests/ab-test-target-based.test.ts create mode 100644 e2e-tests/guardrail-block.test.ts create mode 100644 e2e-tests/utils/recommendation-cleanup.ts delete mode 100644 integ-tests/add-remove-ab-test-target-based.test.ts delete mode 100644 integ-tests/add-remove-ab-test.test.ts create mode 100644 integ-tests/add-remove-online-insights.test.ts create mode 100644 integ-tests/promote-ab-test.test.ts create mode 100644 integ-tests/run-ab-test.test.ts create mode 100644 integ-tests/run-insights.test.ts create mode 100644 integ-tests/run-recommendation-from-insights.test.ts create mode 100644 src/assets/python/http/strands/base/skills/fetcher.py create mode 100644 src/assets/python/http/strands/capabilities/execution-limits/hooks/execution_limits.py create mode 100644 src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts create mode 100644 src/cli/aws/__tests__/agentcore-config-bundles.test.ts delete mode 100644 src/cli/aws/__tests__/agentcore-http-gateways.test.ts create mode 100644 src/cli/aws/__tests__/bedrock-agent.test.ts delete mode 100644 src/cli/aws/agentcore-http-gateways.ts create mode 100644 src/cli/aws/bedrock-agent.ts create mode 100644 src/cli/cloudformation/__tests__/outputs-config-bundles.test.ts create mode 100644 src/cli/cloudformation/__tests__/outputs-harness.test.ts create mode 100644 src/cli/cloudformation/__tests__/parse-kb-outputs.test.ts delete mode 100644 src/cli/commands/abtest/command.ts delete mode 100644 src/cli/commands/abtest/index.ts create mode 100644 src/cli/commands/add/__tests__/add-knowledge-base.test.ts create mode 100644 src/cli/commands/add/__tests__/harness-privatelink-guard.test.ts create mode 100644 src/cli/commands/add/__tests__/skill-action.test.ts create mode 100644 src/cli/commands/add/skill-action.ts create mode 100644 src/cli/commands/add/skill-command.ts create mode 100644 src/cli/commands/batch-evaluations/command.tsx create mode 100644 src/cli/commands/batch-evaluations/index.ts create mode 100644 src/cli/commands/deploy/__tests__/harness-version-drift.test.ts create mode 100644 src/cli/commands/export/__tests__/harness-action.test.ts create mode 100644 src/cli/commands/export/__tests__/harness-mapper.test.ts create mode 100644 src/cli/commands/export/constants.ts create mode 100644 src/cli/commands/export/harness-action.ts create mode 100644 src/cli/commands/export/harness-mapper.ts create mode 100644 src/cli/commands/export/harness-resolver.ts create mode 100644 src/cli/commands/export/index.ts create mode 100644 src/cli/commands/export/types.ts delete mode 100644 src/cli/commands/import/__tests__/import-gateway-targets.test.ts create mode 100644 src/cli/commands/invoke/__tests__/action-gateway.test.ts create mode 100644 src/cli/commands/invoke/__tests__/build-harness-base-opts.test.ts delete mode 100644 src/cli/commands/pause/__tests__/promote.test.ts delete mode 100644 src/cli/commands/pause/promote-utils.ts create mode 100644 src/cli/commands/promote/command.tsx create mode 100644 src/cli/commands/promote/index.ts create mode 100644 src/cli/commands/remove/__tests__/skill-command.test.ts create mode 100644 src/cli/commands/remove/skill-command.ts create mode 100644 src/cli/commands/status/__tests__/format-knowledge-base.test.ts create mode 100644 src/cli/commands/status/format-knowledge-base.ts create mode 100644 src/cli/commands/view/JobDetailScreen.tsx create mode 100644 src/cli/commands/view/command.tsx create mode 100644 src/cli/commands/view/index.ts delete mode 100644 src/cli/operations/ab-test/__tests__/promote.test.ts delete mode 100644 src/cli/operations/ab-test/promote.ts delete mode 100644 src/cli/operations/archive/__tests__/archive-storage.test.ts delete mode 100644 src/cli/operations/archive/archive-storage.ts delete mode 100644 src/cli/operations/archive/index.ts create mode 100644 src/cli/operations/deploy/__tests__/managed-memory-notice.test.ts delete mode 100644 src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts delete mode 100644 src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts delete mode 100644 src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts create mode 100644 src/cli/operations/deploy/__tests__/post-deploy-knowledge-bases.test.ts delete mode 100644 src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts delete mode 100644 src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts delete mode 100644 src/cli/operations/deploy/imperative/deployers/harness-deployer.ts delete mode 100644 src/cli/operations/deploy/imperative/deployers/harness-mapper.ts delete mode 100644 src/cli/operations/deploy/imperative/deployers/index.ts delete mode 100644 src/cli/operations/deploy/imperative/index.ts delete mode 100644 src/cli/operations/deploy/imperative/manager.ts delete mode 100644 src/cli/operations/deploy/imperative/types.ts create mode 100644 src/cli/operations/deploy/managed-memory-notice.ts delete mode 100644 src/cli/operations/deploy/post-deploy-ab-tests.ts delete mode 100644 src/cli/operations/deploy/post-deploy-config-bundles.ts delete mode 100644 src/cli/operations/deploy/post-deploy-http-gateways.ts create mode 100644 src/cli/operations/deploy/post-deploy-knowledge-bases.ts create mode 100644 src/cli/operations/dev/__tests__/sse-transform.test.ts create mode 100644 src/cli/operations/dev/sse-transform.ts delete mode 100644 src/cli/operations/eval/batch-eval-storage.ts delete mode 100644 src/cli/operations/eval/run-batch-evaluation.ts create mode 100644 src/cli/operations/harness/__tests__/orphan.test.ts create mode 100644 src/cli/operations/harness/orphan.ts create mode 100644 src/cli/operations/harness/skill-utils.ts create mode 100644 src/cli/operations/ingest/__tests__/index.test.ts create mode 100644 src/cli/operations/ingest/index.ts create mode 100644 src/cli/operations/insights/index.ts create mode 100644 src/cli/operations/insights/insights-storage.ts create mode 100644 src/cli/operations/insights/run-insights.ts create mode 100644 src/cli/operations/insights/types.ts create mode 100644 src/cli/operations/jobs/ab-test/__tests__/build-options.test.ts create mode 100644 src/cli/operations/jobs/ab-test/__tests__/format.test.ts create mode 100644 src/cli/operations/jobs/ab-test/__tests__/promote.test.ts create mode 100644 src/cli/operations/jobs/ab-test/build-options.ts create mode 100644 src/cli/operations/jobs/ab-test/format.ts create mode 100644 src/cli/operations/jobs/ab-test/handler.ts create mode 100644 src/cli/operations/jobs/ab-test/promote.ts create mode 100644 src/cli/operations/jobs/ab-test/resolve.ts create mode 100644 src/cli/operations/jobs/batch-evaluation/build-source.ts create mode 100644 src/cli/operations/jobs/batch-evaluation/dataset-phase1.ts create mode 100644 src/cli/operations/jobs/batch-evaluation/format.ts create mode 100644 src/cli/operations/jobs/batch-evaluation/handler.ts create mode 100644 src/cli/operations/jobs/index.ts create mode 100644 src/cli/operations/jobs/insights/__tests__/handler.test.ts create mode 100644 src/cli/operations/jobs/insights/format.ts create mode 100644 src/cli/operations/jobs/insights/handler.ts rename src/cli/operations/{ => jobs}/recommendation/__tests__/apply-to-bundle.test.ts (97%) create mode 100644 src/cli/operations/jobs/recommendation/__tests__/auto-name.test.ts rename src/cli/operations/{ => jobs}/recommendation/__tests__/fetch-session-spans.test.ts (99%) create mode 100644 src/cli/operations/jobs/recommendation/__tests__/input-validation.test.ts create mode 100644 src/cli/operations/jobs/recommendation/__tests__/refresh.test.ts rename src/cli/operations/{ => jobs}/recommendation/apply-to-bundle.ts (94%) create mode 100644 src/cli/operations/jobs/recommendation/build-config.ts rename src/cli/operations/{ => jobs}/recommendation/fetch-session-spans.ts (97%) create mode 100644 src/cli/operations/jobs/recommendation/format.ts create mode 100644 src/cli/operations/jobs/recommendation/handler.ts create mode 100644 src/cli/operations/jobs/shared/__tests__/constants.test.ts create mode 100644 src/cli/operations/jobs/shared/__tests__/engine.test.ts create mode 100644 src/cli/operations/jobs/shared/__tests__/region.test.ts create mode 100644 src/cli/operations/jobs/shared/__tests__/storage.test.ts create mode 100644 src/cli/operations/jobs/shared/constants.ts create mode 100644 src/cli/operations/jobs/shared/engine.ts create mode 100644 src/cli/operations/jobs/shared/format.ts create mode 100644 src/cli/operations/jobs/shared/region.ts create mode 100644 src/cli/operations/jobs/shared/resolve-agent-state.ts create mode 100644 src/cli/operations/jobs/shared/storage.ts create mode 100644 src/cli/operations/jobs/shared/types.ts create mode 100644 src/cli/operations/jobs/shared/wait.ts create mode 100644 src/cli/operations/knowledge-base/__tests__/agentic-retrieve-upsert.test.ts create mode 100644 src/cli/operations/knowledge-base/__tests__/connector-config.test.ts create mode 100644 src/cli/operations/knowledge-base/__tests__/hydrate-data-sources.test.ts create mode 100644 src/cli/operations/knowledge-base/__tests__/templates.test.ts create mode 100644 src/cli/operations/knowledge-base/agentic-retrieve-upsert.ts create mode 100644 src/cli/operations/knowledge-base/connector-config.ts create mode 100644 src/cli/operations/knowledge-base/hydrate-data-sources.ts delete mode 100644 src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts delete mode 100644 src/cli/operations/recommendation/__tests__/run-recommendation.test.ts delete mode 100644 src/cli/operations/recommendation/constants.ts delete mode 100644 src/cli/operations/recommendation/index.ts delete mode 100644 src/cli/operations/recommendation/recommendation-storage.ts delete mode 100644 src/cli/operations/recommendation/run-recommendation.ts delete mode 100644 src/cli/operations/recommendation/types.ts delete mode 100644 src/cli/primitives/ABTestPrimitive.ts create mode 100644 src/cli/primitives/KnowledgeBasePrimitive.ts create mode 100644 src/cli/primitives/OnlineInsightsPrimitive.ts delete mode 100644 src/cli/primitives/__tests__/ABTestPrimitive.test.ts create mode 100644 src/cli/primitives/__tests__/GatewayTargetPrimitive.test.ts create mode 100644 src/cli/primitives/__tests__/HarnessPrimitive.remove.test.ts create mode 100644 src/cli/primitives/__tests__/KnowledgeBasePrimitive.test.ts create mode 100644 src/cli/primitives/__tests__/OnlineInsightsPrimitive.test.ts create mode 100644 src/cli/primitives/__tests__/PolicyPrimitive.test.ts create mode 100644 src/cli/tui/__tests__/app-command-coverage.test.ts create mode 100644 src/cli/tui/components/jwt-config/DomainOverridesManager.tsx delete mode 100644 src/cli/tui/hooks/useCreateABTest.ts delete mode 100644 src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx delete mode 100644 src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx delete mode 100644 src/cli/tui/screens/ab-test/AddABTestFlow.tsx delete mode 100644 src/cli/tui/screens/ab-test/AddABTestScreen.tsx delete mode 100644 src/cli/tui/screens/ab-test/RemoveABTestScreen.tsx delete mode 100644 src/cli/tui/screens/ab-test/TargetBasedABTestScreen.tsx delete mode 100644 src/cli/tui/screens/ab-test/VariantConfigForm.tsx delete mode 100644 src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx delete mode 100644 src/cli/tui/screens/ab-test/__tests__/useTargetBasedWizard.test.tsx delete mode 100644 src/cli/tui/screens/ab-test/index.ts delete mode 100644 src/cli/tui/screens/ab-test/types.ts delete mode 100644 src/cli/tui/screens/ab-test/useAddABTestWizard.ts delete mode 100644 src/cli/tui/screens/ab-test/useTargetBasedWizard.ts create mode 100644 src/cli/tui/screens/config-bundle/__tests__/useAddConfigBundleWizard.test.tsx create mode 100644 src/cli/tui/screens/config-bundle/constants.ts create mode 100644 src/cli/tui/screens/export/ExportHarnessFlow.tsx create mode 100644 src/cli/tui/screens/export/ExportHarnessScreen.tsx create mode 100644 src/cli/tui/screens/export/index.ts create mode 100644 src/cli/tui/screens/export/types.ts create mode 100644 src/cli/tui/screens/export/useExportHarnessWizard.ts create mode 100644 src/cli/tui/screens/generate/__tests__/types.test.ts create mode 100644 src/cli/tui/screens/insights-jobs/InsightsJobsScreen.tsx create mode 100644 src/cli/tui/screens/insights-jobs/index.ts create mode 100644 src/cli/tui/screens/job-detail/ABTestDetailView.tsx create mode 100644 src/cli/tui/screens/job-detail/BatchEvalDetailView.tsx create mode 100644 src/cli/tui/screens/job-detail/RecommendationDetailView.tsx create mode 100644 src/cli/tui/screens/job-detail/helpers.ts create mode 100644 src/cli/tui/screens/job-detail/index.ts create mode 100644 src/cli/tui/screens/knowledge-base/AddKnowledgeBaseFlow.tsx create mode 100644 src/cli/tui/screens/knowledge-base/AddKnowledgeBaseScreen.tsx create mode 100644 src/cli/tui/screens/knowledge-base/__tests__/AddKnowledgeBaseFlow.test.tsx create mode 100644 src/cli/tui/screens/knowledge-base/__tests__/AddKnowledgeBaseScreen.test.tsx create mode 100644 src/cli/tui/screens/knowledge-base/__tests__/groupDataSources.test.ts create mode 100644 src/cli/tui/screens/knowledge-base/__tests__/inline-connector-config.test.ts create mode 100644 src/cli/tui/screens/knowledge-base/groupDataSources.ts create mode 100644 src/cli/tui/screens/knowledge-base/index.ts create mode 100644 src/cli/tui/screens/knowledge-base/inline-connector-config.ts create mode 100644 src/cli/tui/screens/knowledge-base/types.ts create mode 100644 src/cli/tui/screens/online-insights/AddOnlineInsightsFlow.tsx create mode 100644 src/cli/tui/screens/online-insights/AddOnlineInsightsScreen.tsx create mode 100644 src/cli/tui/screens/online-insights/index.ts create mode 100644 src/cli/tui/screens/online-insights/types.ts create mode 100644 src/cli/tui/screens/online-insights/useAddOnlineInsightsWizard.ts create mode 100644 src/cli/tui/screens/policy/__tests__/synthesize-cedar.test.ts create mode 100644 src/cli/tui/screens/policy/__tests__/useAddPolicyWizard.render.test.tsx create mode 100644 src/cli/tui/screens/policy/synthesize-cedar.ts create mode 100644 src/cli/tui/screens/remove/RemoveKnowledgeBaseScreen.tsx create mode 100644 src/cli/tui/screens/run-ab-test/ABTestJobsHistoryScreen.tsx create mode 100644 src/cli/tui/screens/run-ab-test/RunABTestFlow.tsx create mode 100644 src/cli/tui/screens/run-ab-test/index.ts create mode 100644 src/cli/tui/screens/run-ab-test/types.ts create mode 100644 src/cli/tui/screens/run-eval/RunIngestFlow.tsx create mode 100644 src/cli/tui/screens/run-insights/RunInsightsFlow.tsx create mode 100644 src/cli/tui/screens/run-insights/RunInsightsScreen.tsx create mode 100644 src/cli/tui/screens/run-insights/index.ts create mode 100644 src/cli/tui/screens/run-insights/types.ts create mode 100644 src/cli/tui/screens/run-insights/useRunInsightsWizard.ts create mode 100644 src/cli/tui/screens/view/ViewTypePickerScreen.tsx create mode 100644 src/cli/tui/screens/view/index.ts create mode 100644 src/cli/tui/screens/web-search/AddWebSearchFlow.tsx create mode 100644 src/cli/tui/screens/web-search/AddWebSearchScreen.tsx create mode 100644 src/cli/tui/screens/web-search/index.ts create mode 100644 src/cli/tui/screens/web-search/types.ts create mode 100644 src/schema/schemas/__tests__/online-eval-config.test.ts create mode 100644 src/schema/schemas/__tests__/private-endpoint.test.ts delete mode 100644 src/schema/schemas/primitives/__tests__/http-gateway.test.ts create mode 100644 src/schema/schemas/primitives/__tests__/knowledge-base.test.ts delete mode 100644 src/schema/schemas/primitives/http-gateway.ts create mode 100644 src/schema/schemas/primitives/knowledge-base.ts diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 341ab0f4c..9a0eed3fb 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -2,9 +2,9 @@ name: Build and Test on: push: - branches: ['main'] + branches: ['main', 'feat/**'] pull_request: - branches: ['main'] + branches: ['main', 'feat/**'] permissions: contents: read diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0b3f65d25..8882e4342 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -4,9 +4,9 @@ on: push: branches: ['main'] pull_request: - branches: ['main'] + branches: ['main', 'feat/**'] pull_request_target: - branches: ['main'] + branches: ['main', 'feat/**'] # Cancel in-progress runs for PRs; never cancel runs on main (merges should not abort each other) concurrency: diff --git a/.github/workflows/e2e-tests-full.yml b/.github/workflows/e2e-tests-full.yml index 11310e5a1..e20a6b037 100644 --- a/.github/workflows/e2e-tests-full.yml +++ b/.github/workflows/e2e-tests-full.yml @@ -5,8 +5,11 @@ on: aws_region: description: 'AWS region for deployment' default: 'us-east-1' + cdk_branch: + description: 'Branch of CDK constructs repo to build from' + default: 'main' push: - branches: [main] + branches: [main, 'feat/**'] env: AGENTCORE_TELEMETRY_DISABLED: '1' @@ -32,7 +35,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'main' }} + persist-credentials: false - uses: actions/setup-node@v6 with: node-version: '20.x' @@ -68,9 +71,29 @@ jobs: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: aws - - name: Build CDK package from main + - name: Build CDK package run: | - git clone --depth 1 "https://x-access-token:${CDK_REPO_TOKEN}@github.com/${CDK_REPO}.git" /tmp/cdk-repo + if [ -n "${{ inputs.cdk_branch }}" ] && [ "${{ inputs.cdk_branch }}" != "main" ]; then + CDK_BRANCH="${{ inputs.cdk_branch }}" + elif [ "${{ github.ref_name }}" != "main" ]; then + CDK_BRANCH="main" + REPO_URL="https://x-access-token:${CDK_REPO_TOKEN}@github.com/${CDK_REPO}.git" + # Check if a branch exists on the CDK repo with the same + if git ls-remote --exit-code --heads "$REPO_URL" "${{ github.ref_name }}" > /dev/null 2>&1; then + CDK_BRANCH="${{ github.ref_name }}" + else + # Check if a branch exists with _ subbed for -. (legacy support for summit branch) + ALT="${{ github.ref_name }}" + ALT="${ALT//_/-}" + if git ls-remote --exit-code --heads "$REPO_URL" "$ALT" > /dev/null 2>&1; then + CDK_BRANCH="$ALT" + fi + fi + else + CDK_BRANCH="main" + fi + echo "Using CDK branch: $CDK_BRANCH" + git clone --depth 1 --branch "$CDK_BRANCH" "https://x-access-token:${CDK_REPO_TOKEN}@github.com/${CDK_REPO}.git" /tmp/cdk-repo cd /tmp/cdk-repo npm ci npm run build diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 0d932537e..2d13211d0 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -6,7 +6,7 @@ on: description: 'AWS region for deployment' default: 'us-east-1' pull_request_target: - branches: [main] + branches: [main, feat/**] concurrency: group: e2e-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 24a9317f6..53c7c2ca6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,9 +2,9 @@ name: Quality and Safety Checks on: push: - branches: ['main'] + branches: ['main', 'feat/**'] pull_request: - branches: ['main'] + branches: ['main', 'feat/**'] permissions: contents: read diff --git a/.github/workflows/pr-ai-review.yml b/.github/workflows/pr-ai-review.yml index 26878d7a1..c1e908a3e 100644 --- a/.github/workflows/pr-ai-review.yml +++ b/.github/workflows/pr-ai-review.yml @@ -18,7 +18,10 @@ permissions: jobs: authorize: runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request_target' + # explicitly require the PR to be open to avoid old events triggering a review on closed PRs: https://github.com/aws/agentcore-cli/issues/1463 + if: + github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request_target' && + github.event.pull_request.state == 'open') outputs: authorized: ${{ steps.auth.outputs.authorized }} steps: diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 792f0d728..2f3e588e3 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -4,7 +4,7 @@ name: PR Size Check and Label # Safe because this workflow only reads PR metadata — it never checks out untrusted code. on: pull_request_target: - branches: [main] + branches: [main, feat/**] jobs: label-size: diff --git a/.github/workflows/pr-tarball.yml b/.github/workflows/pr-tarball.yml index 068461940..5da53aa4a 100644 --- a/.github/workflows/pr-tarball.yml +++ b/.github/workflows/pr-tarball.yml @@ -1,7 +1,7 @@ name: PR Tarball on: pull_request_target: - branches: [main] + branches: [main, feat/**] permissions: contents: write diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 3ab976395..873becbd3 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -2,7 +2,7 @@ name: Validate PR Title on: pull_request_target: - branches: [main] + branches: [main, feat/**] types: [opened, edited, synchronize, reopened] permissions: diff --git a/README.md b/README.md index 0dfb08eea..32b3ab3dc 100644 --- a/README.md +++ b/README.md @@ -109,20 +109,20 @@ agentcore invoke ### Evaluations -| Command | Description | -| ----------------------- | ------------------------------------------------ | -| `add evaluator` | Add a custom LLM-as-a-Judge evaluator | -| `add online-eval` | Add continuous evaluation for live traffic | -| `run eval` | Run on-demand evaluation against agent traces | -| `run batch-evaluation` | Run evaluators across all sessions [preview] | -| `run recommendation` | Optimize prompts and tool descriptions [preview] | -| `evals history` | View past eval run results | -| `pause online-eval` | Pause a deployed online eval config | -| `resume online-eval` | Resume a paused online eval config | -| `stop batch-evaluation` | Stop a running batch evaluation [preview] | -| `logs evals` | Stream or search online eval logs | - -### Config Bundles [preview] +| Command | Description | +| ----------------------- | --------------------------------------------- | +| `add evaluator` | Add a custom LLM-as-a-Judge evaluator | +| `add online-eval` | Add continuous evaluation for live traffic | +| `run eval` | Run on-demand evaluation against agent traces | +| `run batch-evaluation` | Run evaluators across all sessions | +| `run recommendation` | Optimize prompts and tool descriptions | +| `evals history` | View past eval run results | +| `pause online-eval` | Pause a deployed online eval config | +| `resume online-eval` | Resume a paused online eval config | +| `stop batch-evaluation` | Stop a running batch evaluation | +| `logs evals` | Stream or search online eval logs | + +### Config Bundles | Command | Description | | ------------------- | ----------------------------------------- | @@ -133,6 +133,18 @@ agentcore invoke > Create agents with `--with-config-bundle` to auto-wire config bundle support into the generated template. +### A/B Tests + +| Command | Description | +| ----------------- | -------------------------------------------------------------- | +| `run ab-test` | Start an A/B test (config-bundle or target-based) on a gateway | +| `view ab-test` | List A/B test jobs or view one in detail | +| `pause ab-test` | Pause traffic splitting for a running test | +| `resume ab-test` | Resume a paused test | +| `stop ab-test` | Stop a running test (terminal) | +| `promote ab-test` | Apply the winning variant to `agentcore.json` | +| `archive ab-test` | Delete the test on the service and clear local history | + ### Utilities | Command | Description | @@ -185,11 +197,13 @@ Projects use JSON schema files in the `agentcore/` directory: - [CLI Commands Reference](docs/commands.md) - Full command reference for scripting and CI/CD - [Configuration](docs/configuration.md) - Schema reference for config files - [Evaluations](docs/evals.md) - Evaluators, on-demand evals, and online monitoring -- [Batch Evaluation](docs/batch-evaluation.md) - Run evaluators across sessions at scale [preview] -- [Recommendations](docs/recommendations.md) - Optimize prompts and tool descriptions [preview] -- [Config Bundles](docs/config-bundles.md) - Versioned runtime configurations [preview] +- [Batch Evaluation](docs/batch-evaluation.md) - Run evaluators across sessions at scale +- [Recommendations](docs/recommendations.md) - Optimize prompts and tool descriptions +- [A/B Tests](docs/ab-tests.md) - Split traffic between variants and promote the winner +- [Config Bundles](docs/config-bundles.md) - Versioned runtime configurations - [Frameworks](docs/frameworks.md) - Supported frameworks and model providers - [Gateway](docs/gateway.md) - Gateway setup, targets, and authentication +- [Knowledge Bases](docs/knowledge-bases.md) - Managed Bedrock Knowledge Bases wired to gateways - [Memory](docs/memory.md) - Memory strategies and sharing - [Local Development](docs/local-development.md) - Dev server and debugging - [Feedback](docs/feedback.md) - Submit feedback from your terminal diff --git a/docs/ab-tests.md b/docs/ab-tests.md new file mode 100644 index 000000000..b8e8d51de --- /dev/null +++ b/docs/ab-tests.md @@ -0,0 +1,154 @@ +# A/B Tests + +A/B tests split live traffic through a gateway between a **control** variant and a **treatment** variant, then use +online evaluation configs to measure which performs better. When you have a winner, `promote` applies it to your project +config. + +A/B tests are **fire-and-forget jobs** (like `run recommendation` and `run batch-evaluation`): you start one with +`run ab-test`, then manage its lifecycle with `view` / `pause` / `resume` / `stop` / `promote` / `archive`. They are +**not** declared in `agentcore.json` and are not created by `deploy` — the gateway, its targets, and any config bundles +must already be deployed first. + +## Two modes + +| Mode | Compares | Variant inputs | +| ------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `config-bundle` (default) | Two **versions of the same config bundle** | `--control-bundle`/`--control-version`, `--treatment-bundle`/`--treatment-version`, shared `--online-eval` | +| `target-based` | Two **gateway targets** (runtime endpoints) | `--control-target`/`--treatment-target`, `--control-online-eval`/`--treatment-online-eval` | + +Each A/B test needs its **own gateway**, and only one test can be RUNNING per gateway at a time. + +## Quick Start + +```bash +# Config-bundle mode: compare two versions of one bundle (50/50 split) +agentcore run ab-test \ + -n PromptTest \ + -g MyGateway \ + --mode config-bundle \ + -r MyAgent \ + --control-bundle MyBundle --control-version \ + --treatment-bundle MyBundle --treatment-version \ + --online-eval MyEvalConfig + +# Target-based mode: compare two gateway targets +agentcore run ab-test \ + -n TargetTest \ + -g MyGateway \ + --mode target-based \ + -r MyAgent \ + --control-target prodTarget \ + --treatment-target stagingTarget \ + --control-online-eval ctrlEval \ + --treatment-online-eval treatEval +``` + +A test is enabled (RUNNING) on create by default. Pass `--disable-on-create` to create it stopped. + +## `run ab-test` options + +| Flag | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------- | +| `-n, --name ` | Name for the A/B test | +| `-g, --gateway ` | Gateway name (must already be deployed) | +| `-m, --mode ` | `config-bundle` (default) or `target-based` | +| `-r, --runtime ` | Runtime name (recorded as the agent) | +| `--control-weight ` | Control traffic weight 0–100 (default 50) | +| `--treatment-weight ` | Treatment traffic weight 0–100 (default 50) | +| `--max-duration-days ` | Auto-stop the test after this many days | +| `--role-arn ` | Execution role ARN (auto-created if omitted) | +| `--disable-on-create` | Create the test without starting it (default: enabled) | +| `--gateway-filter ` | Restrict the test to a single gateway target path (e.g. `/orders/*`); applies to both modes | +| `--region ` | AWS region (auto-detected if omitted) | +| `--wait` | Block until the test reaches a terminal state | +| `--json` | JSON output | +| **config-bundle mode** | | +| `--control-bundle ` | Control bundle name or ARN | +| `--control-version ` | Control bundle version (or `LATEST`) | +| `--treatment-bundle ` | Treatment bundle name or ARN | +| `--treatment-version ` | Treatment bundle version (or `LATEST`) | +| `--online-eval ` | Shared online eval config name or ARN | +| `--traffic-header ` | Route traffic on this header instead of by weight | +| **target-based mode** | | +| `--control-target ` | Control gateway-target name | +| `--treatment-target ` | Treatment gateway-target name | +| `--control-online-eval ` | Online eval for the control endpoint (required) | +| `--treatment-online-eval ` | Online eval for the treatment endpoint (required) | + +Names must start with a letter and contain only letters, digits, underscores, and hyphens (max 48 characters). + +## Lifecycle + +All lifecycle commands take the test's **job ID** via `-i, --id` (get it from `run ab-test --json` or `view ab-test`): + +```bash +# List all A/B test jobs, or view one in detail +agentcore view ab-test +agentcore view ab-test --json + +# Pause / resume traffic splitting +agentcore pause ab-test -i +agentcore resume ab-test -i + +# Stop the test (terminal) +agentcore stop ab-test -i + +# Apply the winning variant to agentcore.json, then deploy to roll it out +agentcore promote ab-test -i +agentcore deploy + +# Remove the job from local history (and the test from the service) +agentcore archive ab-test -i +``` + +### Promote + +`promote` writes the winning (treatment) variant into `agentcore.json`: + +- **config-bundle mode** — control and treatment must be two **versions of the same bundle**; promote adopts the + treatment version's components into that bundle. Promoting across two _different_ bundles is rejected. +- **target-based mode** — control adopts the treatment endpoint: when both are named endpoints of the same runtime, + control's endpoint version is bumped to the treatment's; otherwise the control target is repointed to the treatment's + runtime/endpoint. + +Promote does not deploy — review the change and run `agentcore deploy` to roll it out. + +## Invocation URL + +`view ab-test ` shows an **Invocation URL** derived from the test's gateway. Send traffic there and the gateway +splits it between the variants per the configured weights: + +``` +https://.gateway.bedrock-agentcore..amazonaws.com//invocations +``` + +(target-based uses the control target's path; config-bundle uses the agent name.) + +## Results + +`view ab-test ` shows, once the online evals have scored enough traffic, per-evaluator metrics: the control mean, +each treatment's mean with percent change, and a significance marker. `--json` includes the same under +`results.evaluatorMetrics`, plus `status`, `executionStatus`, `variants`, and `invocationUrl`. + +## Local history + +Job records are saved under `.cli/jobs/ab-tests/`. Browse them in the TUI: + +```bash +agentcore +# Navigate to: Run → A/B Tests (or View → A/B Tests) +``` + +## TUI Wizard + +Run `agentcore` → Run → A/B Test for a guided flow: + +1. Select mode (config-bundle or target-based) +2. Select the gateway +3. Pick control + treatment variants (bundle versions, or gateway targets) +4. Select online eval config(s) +5. Optionally set a gateway filter +6. Name the test and confirm + +Selecting a test from the A/B Tests list shows its detail (status, variants, invocation URL, results) with keybindings +to pause/resume/stop/promote/debug. diff --git a/docs/batch-evaluation.md b/docs/batch-evaluation.md index ea13d3707..afd4744d4 100644 --- a/docs/batch-evaluation.md +++ b/docs/batch-evaluation.md @@ -1,4 +1,4 @@ -# Batch Evaluation [preview] +# Batch Evaluation Batch evaluation runs evaluators across all agent sessions in CloudWatch, producing per-session scores and aggregate metrics. Use it to measure agent quality over time, compare before/after prompt changes, or validate ground truth @@ -13,6 +13,9 @@ agentcore run batch-evaluation -r MyAgent -e Builtin.Correctness # Multiple evaluators agentcore run batch-evaluation -r MyAgent -e Builtin.Correctness Builtin.Helpfulness Builtin.Faithfulness +# Reference evaluators by ARN (custom or cross-account) +agentcore run batch-evaluation -r MyAgent --evaluator-arn arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/MyCustomEval + # JSON output for scripting agentcore run batch-evaluation -r MyAgent -e Builtin.Helpfulness --json ``` @@ -90,6 +93,27 @@ All fields inside `inline` are optional — include only what's relevant: - `expectedTrajectory` — tool call sequence evaluated by `Builtin.TrajectoryExactOrderMatch` - `turns` — input/expected-response pairs evaluated by `Builtin.Correctness` +## Dataset-Driven Evaluation + +Instead of scoring historical CloudWatch traces, drive the evaluation from a **dataset** — the CLI invokes the agent +with each dataset scenario, then scores the results: + +```bash +# Use the local DRAFT dataset file +agentcore run batch-evaluation -r MyAgent -e Builtin.Correctness --dataset MyScenarios + +# Use a published dataset version +agentcore run batch-evaluation -r MyAgent -e Builtin.Correctness --dataset MyScenarios --dataset-version 1 +``` + +| Flag | Description | +| ----------------------------- | -------------------------------------------------------------------- | +| `--dataset ` | Dataset name — invoke the agent with its scenarios instead of traces | +| `--dataset-version ` | Dataset version (`N` or `DRAFT`; omit to use the local file) | + +Add and edit datasets with `agentcore add dataset` and `agentcore dataset publish-version`. The number of scored +sessions equals the number of scenarios in the dataset. + ## Custom Name ```bash @@ -98,6 +122,21 @@ agentcore run batch-evaluation -r MyAgent -e Builtin.Helpfulness -n "weekly_qual Names must start with a letter and contain only letters, digits, and underscores (max 48 characters). +## Encrypting Results with KMS + +By default, batch evaluation results are encrypted with an AWS-managed key. To encrypt them with your own customer +managed key (CMK), pass its ARN with `--kms-key`: + +```bash +agentcore run batch-evaluation \ + -r MyAgent \ + -e Builtin.Correctness \ + --kms-key arn:aws:kms:us-west-2:111122223333:key/12345678-1234-1234-1234-123456789012 +``` + +The key must be in the same region as the evaluation, and the calling principal (and the AgentCore service) must have +`kms:Encrypt`/`kms:GenerateDataKey` permissions on it. Omit the flag to use the AWS-managed key. + ## Stopping a Running Evaluation ```bash @@ -112,11 +151,11 @@ The CLI shows scores grouped by evaluator with average scores after the run comp ### Local history -Results are saved in `.cli/eval-job-results/`. View past runs via the TUI: +Job records are saved in `.cli/jobs/batch-eval-results/`. View past runs via the TUI: ```bash agentcore -# Navigate to: Evals → Batch Evaluation History +# Navigate to: Run → Batch Evaluation History (or View → Batch Evaluation) ``` ### JSON output diff --git a/docs/commands.md b/docs/commands.md index 6c6881692..84f8856ac 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -96,7 +96,7 @@ agentcore create \ | `--idle-timeout ` | Idle session timeout in seconds | | `--max-lifetime ` | Max instance lifetime in seconds | | `--session-storage-mount-path ` | Absolute mount path for session filesystem storage under `/mnt` (e.g. `/mnt/data`) | -| `--with-config-bundle` | [preview] Create a config bundle wired into the generated agent template | +| `--with-config-bundle` | Create a config bundle wired into the generated agent template | | `--output-dir ` | Output directory | | `--skip-git` | Skip git initialization | | `--skip-python-setup` | Skip venv setup | @@ -314,7 +314,7 @@ agentcore add agent \ | `--client-secret ` | OAuth client secret | | `--request-header-allowlist ` | Comma-separated list of inbound header names to forward to the agent. `X-*` names (e.g. `X-Api-Key`, `X-Custom-Signature`) pass through unchanged; bare names without an `X-` prefix are auto-prefixed with the legacy `X-Amzn-Bedrock-AgentCore-Runtime-Custom-` prefix for backward compatibility. | | `--session-storage-mount-path ` | Absolute mount path for session filesystem storage (e.g. `/mnt/session-storage`) | -| `--with-config-bundle` | [preview] Wire a config bundle into the generated agent template | +| `--with-config-bundle` | Wire a config bundle into the generated agent template | | `--idle-timeout ` | Idle session timeout in seconds | | `--max-lifetime ` | Max instance lifetime in seconds | | `--json` | JSON output | @@ -745,8 +745,8 @@ agentcore add dataset \ ### add config-bundle -[preview] Add a configuration bundle. Config bundles snapshot system prompts, tool descriptions, and runtime config so -they can be versioned and used as A/B test arms. +Add a configuration bundle. Config bundles snapshot system prompts, tool descriptions, and runtime config so they can be +versioned and used as A/B test arms. ```bash agentcore add config-bundle \ @@ -765,40 +765,6 @@ agentcore add config-bundle \ | `--commit-message ` | Commit message for this version | | `--json` | JSON output | -### add ab-test - -[preview] Add an A/B test. Two modes: `config-bundle` (default; split traffic between two bundle versions) and -`target-based` (split traffic between two HTTP gateway targets). - -```bash -agentcore add ab-test \ - --name PromptComparison \ - --runtime MyAgent \ - --control-bundle ProdBundle --control-version 5 \ - --treatment-bundle ExperimentalBundle --treatment-version 2 \ - --control-weight 80 --treatment-weight 20 \ - --enable -``` - -| Flag | Description | -| --------------------------- | --------------------------------------------------------- | -| `--mode ` | `config-bundle` (default) or `target-based` | -| `--name ` | AB test name | -| `--description ` | AB test description | -| `--role-arn ` | IAM role ARN (auto-created if omitted) | -| `--control-weight ` | Traffic weight for control (1–100) | -| `--treatment-weight ` | Traffic weight for treatment (1–100) | -| `--gateway ` | HTTP gateway name | -| `--enable` | Enable the AB test on creation | -| `--runtime ` | (config-bundle mode) Runtime agent to A/B test | -| `--control-bundle ` | (config-bundle mode) Control config bundle name or ARN | -| `--control-version ` | (config-bundle mode) Control config bundle version | -| `--treatment-bundle ` | (config-bundle mode) Treatment config bundle name or ARN | -| `--treatment-version ` | (config-bundle mode) Treatment config bundle version | -| `--online-eval ` | (config-bundle mode) Online evaluation config name or ARN | -| `--traffic-header ` | (config-bundle mode) Header name for traffic routing | -| `--json` | JSON output | - ### remove Remove resources from project. @@ -816,7 +782,6 @@ agentcore remove policy --name AdminAccess --engine MyPolicyEngine agentcore remove runtime-endpoint --name prod agentcore remove dataset --name MyDataset agentcore remove config-bundle --name MyBundle -agentcore remove ab-test --name PromptComparison agentcore remove payment-manager --name MyManager -y agentcore remove payment-connector --name MyCDPConnector --manager MyManager -y @@ -1020,7 +985,7 @@ agentcore run eval \ ### run batch-evaluation -[preview] Run evaluators in batch across all agent sessions found in CloudWatch. +Run evaluators in batch across all agent sessions found in CloudWatch. ```bash # Single evaluator across recent sessions @@ -1044,6 +1009,7 @@ agentcore run batch-evaluation \ | ----------------------------- | ------------------------------------------------------------------------------------------------------ | | `-r, --runtime ` | Runtime name from project config | | `-e, --evaluator ` | Evaluator name(s) — `Builtin.*` IDs | +| `--evaluator-arn ` | Evaluator ARN(s) — use instead of `-e` when referencing evaluators by ARN | | `-n, --name ` | Name for the batch evaluation (auto-generated if omitted) | | `-d, --lookback-days ` | Lookback window in days | | `-s, --session-ids ` | Specific session IDs to evaluate | @@ -1052,11 +1018,12 @@ agentcore run batch-evaluation \ | `--endpoint ` | Runtime endpoint name (e.g. `PROMPT_V1`); defaults to `AGENTCORE_RUNTIME_ENDPOINT` env, then `DEFAULT` | | `--dataset ` | Dataset name — invoke agent with dataset scenarios before batch evaluation | | `--dataset-version ` | Dataset version (omit for local file, or `N`/`DRAFT`) | +| `--kms-key ` | KMS key ARN for encrypting batch evaluation results (default: AWS-managed key) | | `--json` | JSON output | ### run recommendation -[preview] Optimize a system prompt or tool descriptions using agent traces as the signal. +Optimize a system prompt or tool descriptions using agent traces as the signal. ```bash # Optimize a system prompt from an inline string @@ -1104,11 +1071,12 @@ agentcore run recommendation \ | `-s, --session-id ` | Limit trace collection to specific session IDs | | `-n, --run ` | Run name prefix for the recommendation | | `--region ` | AWS region | +| `--kms-key ` | KMS key ARN for encrypting recommendation results (default: AWS-managed key) | | `--json` | JSON output | ### recommendations history -[preview] Show past recommendation runs saved locally. +Show past recommendation runs saved locally. ```bash agentcore recommendations history @@ -1119,6 +1087,51 @@ agentcore recommendations history --json | -------- | ----------- | | `--json` | JSON output | +### run ab-test + +Start an A/B test comparing two config-bundle versions or two gateway targets through a gateway. A fire-and-forget job — +manage it afterward with `view` / `pause` / `resume` / `stop` / `promote` / `archive ab-test`. The gateway (and its +targets / config bundles) must already be deployed. See [docs/ab-tests.md](ab-tests.md). + +```bash +# config-bundle mode (default): two versions of one bundle +agentcore run ab-test -n PromptTest -g MyGateway -r MyAgent \ + --control-bundle MyBundle --control-version \ + --treatment-bundle MyBundle --treatment-version \ + --online-eval MyEvalConfig + +# target-based mode: two gateway targets +agentcore run ab-test -n TargetTest -g MyGateway --mode target-based -r MyAgent \ + --control-target prodTarget --treatment-target stagingTarget \ + --control-online-eval ctrlEval --treatment-online-eval treatEval +``` + +| Flag | Description | +| -------------------------------- | ------------------------------------------------------------ | +| `-n, --name ` | A/B test name (letters/digits/`_`/`-`, max 48) | +| `-g, --gateway ` | Gateway name (must already be deployed) | +| `-m, --mode ` | `config-bundle` (default) or `target-based` | +| `-r, --runtime ` | Runtime name (recorded as the agent) | +| `--control-weight ` | Control traffic weight 0–100 (default 50) | +| `--treatment-weight ` | Treatment traffic weight 0–100 (default 50) | +| `--max-duration-days ` | Auto-stop after this many days | +| `--role-arn ` | Execution role ARN (auto-created if omitted) | +| `--disable-on-create` | Create without starting (default: enabled) | +| `--gateway-filter ` | Restrict to a single gateway target path (both modes) | +| `--wait` | Block until terminal state | +| `--region ` | AWS region | +| `--json` | JSON output | +| `--control-bundle ` | (config-bundle) Control bundle name or ARN | +| `--control-version ` | (config-bundle) Control bundle version (or `LATEST`) | +| `--treatment-bundle ` | (config-bundle) Treatment bundle name or ARN | +| `--treatment-version ` | (config-bundle) Treatment bundle version (or `LATEST`) | +| `--online-eval ` | (config-bundle) Shared online eval config name or ARN | +| `--traffic-header ` | (config-bundle) Route traffic on this header | +| `--control-target ` | (target-based) Control gateway-target name | +| `--treatment-target ` | (target-based) Treatment gateway-target name | +| `--control-online-eval ` | (target-based) Online eval for control endpoint (required) | +| `--treatment-online-eval ` | (target-based) Online eval for treatment endpoint (required) | + ### evals history View past on-demand eval run results. @@ -1197,8 +1210,8 @@ Stop a running batch evaluation or a deployed A/B test. agentcore stop batch-evaluation -i agentcore stop batch-evaluation -i --json -# Stop a deployed A/B test (permanent) -agentcore stop ab-test PromptComparison +# Stop a running A/B test (terminal) +agentcore stop ab-test -i ``` #### `stop batch-evaluation` @@ -1211,15 +1224,15 @@ agentcore stop ab-test PromptComparison #### `stop ab-test` -| Argument / Flag | Description | -| ------------------- | ------------ | -| `` | AB test name | -| `--region ` | AWS region | -| `--json` | JSON output | +| Flag | Description | +| ------------------- | ------------------------------------- | +| `-i, --id ` | A/B test ID to stop | +| `--region ` | AWS region (auto-detected if omitted) | +| `--json` | JSON output | ### archive -[preview] Archive (delete) a batch evaluation or recommendation on the service and clear local history. Irreversible. +Archive (delete) a batch evaluation, recommendation, or A/B test on the service and clear local history. Irreversible. ```bash # Archive a batch evaluation @@ -1228,46 +1241,64 @@ agentcore archive batch-evaluation -i --region us-west-2 --json # Archive a recommendation agentcore archive recommendation -i + +# Archive an A/B test +agentcore archive ab-test -i ``` -Both `archive batch-evaluation` and `archive recommendation` accept the same flags: +`archive batch-evaluation`, `archive recommendation`, and `archive ab-test` accept the same flags: -| Flag | Description | -| ------------------- | -------------------------------------------- | -| `-i, --id ` | ID of the batch evaluation or recommendation | -| `--region ` | AWS region (auto-detected if omitted) | -| `--json` | JSON output | +| Flag | Description | +| ------------------- | ------------------------------------------------------- | +| `-i, --id ` | ID of the batch evaluation, recommendation, or A/B test | +| `--region ` | AWS region (auto-detected if omitted) | +| `--json` | JSON output | -### ab-test +### view -[preview] View A/B test details and results. +View job history and details. Works for all four job types — `recommendation`, `batch-evaluation`, `ab-test`, and +`insights`. With no `[id]` it lists every job of that type; with an `[id]` it shows that job's detail (status, inputs, +and results). Without `--json` the command launches the interactive TUI; with `--json` it prints a machine-readable +record (the `ab-test` detail also includes `invocationUrl`). ```bash -agentcore ab-test PromptComparison -agentcore ab-test PromptComparison --json +# List all jobs of a type +agentcore view recommendation +agentcore view batch-evaluation +agentcore view ab-test +agentcore view insights + +# Detail for one job (JSON is non-interactive) +agentcore view recommendation --json +agentcore view batch-evaluation --json +agentcore view ab-test --json # JSON includes invocationUrl + results ``` -| Argument / Flag | Description | -| ------------------- | ------------ | -| `` | AB test name | -| `--region ` | AWS region | -| `--json` | JSON output | +Each `view ` subcommand accepts the same argument and flags: + +| Argument / Flag | Description | +| ------------------- | ------------------------------------------ | +| `[id]` | Job ID (omit to list all jobs of the type) | +| `--region ` | AWS region (auto-detected if omitted) | +| `--json` | JSON output (non-interactive) | + +A/B tests are also paused/resumed/promoted by ID — see [docs/ab-tests.md](ab-tests.md) for the full lifecycle. ### config-bundle -[preview] Manage configuration bundles. Use the bundle name from `agentcore.json`, not the bundle ID. Aliased as `cb`. +Manage configuration bundles. Use the bundle name from `agentcore.json`, not the bundle ID. Aliased as `cb`. ```bash # List version history -agentcore config-bundle versions --bundle MyBundle -agentcore cb versions --bundle MyBundle --latest-per-branch --json +agentcore config-bundle versions --name MyBundle +agentcore cb versions --name MyBundle --latest-per-branch --json # Diff two versions -agentcore config-bundle diff --bundle MyBundle --from --to +agentcore config-bundle diff --name MyBundle --from --to # Create a new branch from an existing version agentcore config-bundle create-branch \ - --bundle MyBundle \ + --name MyBundle \ --branch experimental \ --from \ --commit-message "Branch off prod for experiments" @@ -1277,7 +1308,7 @@ agentcore config-bundle create-branch \ | Flag | Description | | --------------------- | ------------------------------------------------------ | -| `--bundle ` | Bundle name as defined in `agentcore.json` | +| `--name ` | Bundle name as defined in `agentcore.json` | | `--branch ` | Filter by branch name | | `--latest-per-branch` | Show only the latest version per branch | | `--created-by ` | Filter by creator name (e.g. `user`, `recommendation`) | @@ -1288,7 +1319,7 @@ agentcore config-bundle create-branch \ | Flag | Description | | ------------------- | --------------------------------------------- | -| `--bundle ` | Bundle name | +| `--name ` | Bundle name | | `--from ` | Source version ID (from `cb versions --json`) | | `--to ` | Target version ID (from `cb versions --json`) | | `--region ` | AWS region override | @@ -1298,7 +1329,7 @@ agentcore config-bundle create-branch \ | Flag | Description | | ------------------------- | ----------------------------------------------------- | -| `--bundle ` | Bundle name | +| `--name ` | Bundle name | | `--branch ` | Name for the new branch | | `--from ` | Parent version ID to branch from (defaults to latest) | | `--commit-message ` | Commit message for the branch point | diff --git a/docs/config-bundles.md b/docs/config-bundles.md index 890ad7aaf..f8505294d 100644 --- a/docs/config-bundles.md +++ b/docs/config-bundles.md @@ -1,4 +1,4 @@ -# Configuration Bundles [preview] +# Configuration Bundles Config bundles are versioned configurations that store your agent's runtime settings — system prompt, tool descriptions, model parameters, or any custom keys. Instead of hardcoding values in your agent code, your agent reads its config at @@ -59,7 +59,7 @@ On deploy, the CLI creates or updates the config bundle in the API and stores th ### List versions ```bash -agentcore cb versions --bundle MyBundle +agentcore cb versions --name MyBundle ``` Shows version history grouped by branch with commit messages, timestamps, and parent lineage. @@ -67,13 +67,13 @@ Shows version history grouped by branch with commit messages, timestamps, and pa ### Diff two versions ```bash -agentcore cb diff --bundle MyBundle --from --to +agentcore cb diff --name MyBundle --from --to ``` ### Create a branch ```bash -agentcore cb create-branch --bundle MyBundle --branch experiment-1 +agentcore cb create-branch --name MyBundle --branch experiment-1 ``` Creates a new branch from the latest version (or a specific version with `--from`). @@ -108,7 +108,7 @@ automatically. All commands support `--json` for scripting: ```bash -agentcore cb versions --bundle MyBundle --json -agentcore cb diff --bundle MyBundle --from v1 --to v2 --json -agentcore cb create-branch --bundle MyBundle --branch exp-1 --json +agentcore cb versions --name MyBundle --json +agentcore cb diff --name MyBundle --from v1 --to v2 --json +agentcore cb create-branch --name MyBundle --branch exp-1 --json ``` diff --git a/docs/connector-config-templates/README.md b/docs/connector-config-templates/README.md new file mode 100644 index 000000000..9208bea93 --- /dev/null +++ b/docs/connector-config-templates/README.md @@ -0,0 +1,71 @@ +# Connector config templates + +Sample `--connector-config` JSON files for non-S3 FMKB data sources. Copy the one matching your source, fill in the real +host/tenant/secret values (and replace any `<...>` placeholders), then: + + agentcore add knowledge-base --name my-kb \ + --data-source-type web-crawler \ + --connector-config ./web-crawler.json + +The CLI copies the file under `app//` and stores the relative path in `agentcore.json`. The JSON is passed +through to the Bedrock DataSource's `connectorParameters` verbatim — Bedrock validates field values, not the CLI, so +typos in enum values surface as a `FAILED` DataSource on first deploy. + +## `--data-source-type` → wire `type` mapping + +| Flag value | Wire `type` | Auth required | +| -------------- | ------------- | --------------------------- | +| `web-crawler` | `WEB` | No | +| `confluence` | `CONFLUENCE` | Secrets Manager `secretArn` | +| `sharepoint` | `SHAREPOINT` | Secrets Manager `secretArn` | +| `onedrive` | `ONEDRIVE` | Secrets Manager `secretArn` | +| `google-drive` | `GOOGLEDRIVE` | Secrets Manager `secretArn` | + +For the auth connectors, set the secret ARN under the connector's `authConfiguration.credentialsSecretArn`. The KB +service role is granted `secretsmanager:GetSecretValue` on it at deploy. + +## Field-value gotchas + +Bedrock validates connector-config field values when it creates the DataSource. The CLI doesn't pre-validate enum values +— if you typo one, the DataSource lands in `FAILED` state on first deploy and the failure reason cites the exact +constraint. A few that bite customers: + +### Web Crawler `syncScope` + +Valid values: `PATH_SPECIFIC`, `SUB_DOMAINS`, `ALL_DOMAINS`, `DOMAINS_ONLY`. Any other value (including the +intuitive-sounding `HOST_ONLY`) fails on creation. Pick the scope that matches your seed URLs: + +- **`PATH_SPECIFIC`** — crawl only URLs that share the path prefix of each seed URL. Most restrictive. +- **`SUB_DOMAINS`** — crawl seed hosts and their subdomains. +- **`ALL_DOMAINS`** — crawl any URL reachable from the seed; only the seed list bounds the crawl. Broadest. +- **`DOMAINS_ONLY`** — crawl only the exact host(s) of the seed URLs. No subdomains, no offsite. + +### Auth connectors require a real `credentialsSecretArn` + +The placeholder ARN values in the templates fail validation at deploy. Create the secret first +(`aws secretsmanager create-secret ...`), then paste its ARN into the config file before running +`agentcore add knowledge-base`. + +### Web Crawler `seedUrls` + +Must be a non-empty array of fully-qualified `https://` URLs. Values without a scheme, or `http://` for hosts that +require TLS, fail at first crawl rather than at create-time. + +## Diagnosing a `FAILED` DataSource + +```bash +agentcore status --type knowledge-base --name +``` + +The drill-down view surfaces the failure reason from Bedrock. For a deeper look: + +```bash +aws bedrock-agent get-data-source \ + --knowledge-base-id \ + --data-source-id \ + --region us-west-2 \ + --query 'dataSource.failureReasons' +``` + +Fix the value in the JSON file under `app//.json`, then `agentcore deploy` to update the DataSource and +re-trigger ingestion. diff --git a/docs/connector-config-templates/confluence.json b/docs/connector-config-templates/confluence.json new file mode 100644 index 000000000..4928ac72b --- /dev/null +++ b/docs/connector-config-templates/confluence.json @@ -0,0 +1,16 @@ +{ + "type": "CONFLUENCE", + "version": "1", + "connectionConfiguration": { + "hostUrl": "https://your-domain.atlassian.net", + "authType": "OAUTH2", + "type": "SAAS", + "secretArn": "arn:aws:secretsmanager:us-west-2:111122223333:secret:confluence-creds", + "rotateSecret": false + }, + "dataEntityConfiguration": { + "crawlPage": true, + "crawlBlog": false, + "crawlPageAttachment": true + } +} diff --git a/docs/connector-config-templates/google-drive.json b/docs/connector-config-templates/google-drive.json new file mode 100644 index 000000000..46be07271 --- /dev/null +++ b/docs/connector-config-templates/google-drive.json @@ -0,0 +1,12 @@ +{ + "type": "GOOGLEDRIVE", + "version": "1", + "connectionConfiguration": { + "authType": "SERVICE_ACCOUNT", + "secretArn": "arn:aws:secretsmanager:us-west-2:111122223333:secret:gdrive-creds" + }, + "dataEntityConfiguration": { + "crawlMyDrive": true, + "crawlSharedDrives": false + } +} diff --git a/docs/connector-config-templates/onedrive.json b/docs/connector-config-templates/onedrive.json new file mode 100644 index 000000000..d454ff156 --- /dev/null +++ b/docs/connector-config-templates/onedrive.json @@ -0,0 +1,16 @@ +{ + "type": "ONEDRIVE", + "version": "1", + "connectionConfiguration": { + "tenantId": "00000000-0000-0000-0000-000000000000", + "authType": "OAUTH2", + "secretArn": "arn:aws:secretsmanager:us-west-2:111122223333:secret:onedrive-creds" + }, + "entitySelectionConfiguration": { + "userSelectionMethod": "USER_EMAILS", + "userEmails": ["user@your-domain.com"] + }, + "dataEntityConfiguration": { + "crawlPersonalDrives": true + } +} diff --git a/docs/connector-config-templates/sharepoint.json b/docs/connector-config-templates/sharepoint.json new file mode 100644 index 000000000..03d3cff6a --- /dev/null +++ b/docs/connector-config-templates/sharepoint.json @@ -0,0 +1,14 @@ +{ + "type": "SHAREPOINT", + "version": "1", + "connectionConfiguration": { + "tenantId": "00000000-0000-0000-0000-000000000000", + "authType": "OAUTH2_APP", + "secretArn": "arn:aws:secretsmanager:us-west-2:111122223333:secret:sharepoint-creds" + }, + "dataEntityConfiguration": { + "siteUrls": ["https://your-tenant.sharepoint.com/sites/your-site"], + "crawlFiles": true, + "crawlPages": true + } +} diff --git a/docs/connector-config-templates/web-crawler.json b/docs/connector-config-templates/web-crawler.json new file mode 100644 index 000000000..2bc36489a --- /dev/null +++ b/docs/connector-config-templates/web-crawler.json @@ -0,0 +1,16 @@ +{ + "type": "WEB", + "version": "1", + "connectionConfiguration": { + "authType": "NO_AUTH", + "seedUrls": ["https://docs.example.com/"], + "siteMapUrls": [] + }, + "crawlConfiguration": { + "syncScope": "PATH_SPECIFIC", + "crawlDepth": 2, + "maxCrawledUrlsPerMinute": 300, + "maxLinksPerUrl": 100, + "crawlAttachments": false + } +} diff --git a/docs/knowledge-bases.md b/docs/knowledge-bases.md new file mode 100644 index 000000000..6a5284990 --- /dev/null +++ b/docs/knowledge-bases.md @@ -0,0 +1,295 @@ +# Knowledge Bases + +A Knowledge Base (KB) ingests documents from one or more data sources and exposes a managed `retrieve` tool to your +agent through a gateway. The CLI provisions the underlying Bedrock fully-managed Knowledge Base, its data sources, and +its IAM service role; you only describe the corpus and the gateway you want it wired to. + +## Quick Start + +The simplest path mirrors the gateway flow: set up the KB and gateway before adding the agent so the generated agent +code is wired to call `retrieve` against the KB through the gateway. + +```bash +# 1. Create a project +agentcore create --name MyProject --defaults +cd MyProject + +# 2. Add a gateway +agentcore add gateway --name docs-gw + +# 3. Add a knowledge base, wired to the gateway +agentcore add knowledge-base \ + --name docs \ + --source s3://my-corpus-bucket/manuals/ \ + --gateway docs-gw + +# 4. Create an agent (automatically wired to the gateway) +agentcore add agent --name MyAgent --framework Strands --model-provider Bedrock + +# 5. Deploy +agentcore deploy -y +``` + +The deploy creates the KB and its data sources, kicks off an initial ingestion job, and exposes a `retrieve` tool on +`docs-gw` that your agent can call. + +## Adding a Knowledge Base + +Three forms work, and they compose: + +```bash +# Interactive — drops into the TUI wizard +agentcore add knowledge-base + +# Non-interactive — required flags only +agentcore add knowledge-base --name docs --source s3://bucket/prefix/ + +# Append a second source to an existing KB (idempotent) +agentcore add knowledge-base --name docs --source s3://bucket/another/ +``` + +Re-invoking `add knowledge-base` with an existing `--name` appends data sources rather than creating a duplicate KB. + +### Wiring to a gateway + +Pass `--gateway ` to attach the KB to a gateway. The CLI creates two connector targets on that gateway: + +- a per-KB `bedrock-knowledge-bases` target named after the KB (single-KB Retrieve), and +- a shared `bedrock-agentic-retrieve` target named `-agentic` that fans out across every KB on the gateway. + +```bash +agentcore add knowledge-base --name docs --source s3://bucket/ --gateway docs-gw +``` + +If `docs-gw` doesn't exist yet, run `agentcore add gateway --name docs-gw` first. The KB add fails fast if the gateway +is missing. + +### Multiple data sources per KB + +Repeat `--source` (S3) or `--connector-config` (non-S3) on the same `--name` invocation, or call `add knowledge-base` +multiple times with the same name: + +```bash +agentcore add knowledge-base --name docs \ + --source s3://bucket/manuals/ \ + --source s3://bucket/changelog.md +``` + +Each source becomes its own data source under the KB and gets its own ingestion job. + +## Data Source Types + +`--data-source-type` selects the kind of data source. Defaults to `s3`. Supported values: + +| Type | Flag value | Required input | Notes | +| ------------ | -------------- | --------------------------- | ---------------------------------------------------------- | +| Amazon S3 | `s3` (default) | `--source ` | Bucket must be in the same account; `s3://bucket[/prefix]` | +| Web Crawler | `web-crawler` | `--connector-config ` | Crawls one or more seed URLs | +| Confluence | `confluence` | `--connector-config ` | Requires Secrets Manager credentials | +| SharePoint | `sharepoint` | `--connector-config ` | Requires Secrets Manager credentials | +| OneDrive | `onedrive` | `--connector-config ` | Requires Secrets Manager credentials | +| Google Drive | `google-drive` | `--connector-config ` | Requires Secrets Manager credentials | + +### S3 sources + +Pass an S3 URI. The bucket must live in the same AWS account where you're deploying; cross-account buckets are not +supported by this connector. + +```bash +agentcore add knowledge-base --name docs \ + --source s3://corpus-bucket-123456789012/manuals/ +``` + +The KB service role is granted `s3:GetObject` and `s3:ListBucket` on every bucket referenced by an S3 data source, +scoped to the deploying account via an `aws:ResourceAccount` condition. Permissions are bucket-scoped, not prefix-scoped +— a KB pointed at `s3://bucket/foo/` can read all of `bucket`. Split into separate buckets if you need prefix-level +isolation. + +### Non-S3 connector sources + +For Web Crawler, Confluence, SharePoint, OneDrive, and Google Drive, you supply a JSON connector-config file. Templates +live at [`docs/connector-config-templates/`](connector-config-templates/) — copy the matching one, fill in the real +host/tenant/secret values, then: + +```bash +agentcore add knowledge-base --name web-docs \ + --data-source-type web-crawler \ + --connector-config ./web-crawler.json +``` + +The CLI copies the file under `app//` and stores the relative path in `agentcore.json`. The JSON contents are +passed verbatim to the Bedrock DataSource's `connectorParameters`. + +Auth-bearing connectors (Confluence, SharePoint, OneDrive, Google Drive) require a Secrets Manager `secretArn` in the +config. The KB service role is granted `secretsmanager:GetSecretValue` on that secret at deploy. + +You can mix data source types on a single KB by repeating `add knowledge-base` with the same `--name`: + +```bash +agentcore add knowledge-base --name docs --source s3://corpus/manuals/ +agentcore add knowledge-base --name docs --data-source-type web-crawler --connector-config ./crawler.json +``` + +## Wiring an External Knowledge Base + +To wire an existing Bedrock KB that this project does not own (created elsewhere, owned by another team), use the +gateway-target primitive directly — there is no `agentcore add knowledge-base` path for external KBs: + +```bash +agentcore add gateway-target \ + --type connector \ + --connector bedrock-knowledge-bases \ + --knowledge-base-id <10-CHAR-KB-ID> \ + --gateway docs-gw \ + --name external-docs +``` + +This writes only to `agentCoreGateways[].targets[]` — no `knowledgeBases[]` entry, no IAM role, no managed ingestion. +The KB lives wherever it lives; the project just adds a Retrieve target on top of it. + +## Ingestion + +`agentcore deploy` automatically kicks off an ingestion job on every data source after the CFN stack finishes. To +re-trigger a manual ingestion later (after updating corpus contents, fixing permissions, etc.): + +```bash +# Ingest all data sources on a KB +agentcore run ingest --name docs + +# Ingest a specific data source on the KB +agentcore run ingest --name docs --data-source s3://corpus/manuals/ + +# JSON output for scripting +agentcore run ingest --name docs --json +``` + +Bedrock allows only one concurrent ingestion job per KB; the CLI retries with backoff if a job is already running. + +## Status + +```bash +# All KBs in the project +agentcore status --type knowledge-base + +# Drill into one KB +agentcore status --type knowledge-base --name docs + +# JSON output +agentcore status --type knowledge-base --json +``` + +The drill-down view shows per-data-source ingestion state, document counts (scanned, indexed, failed), and any +troubleshooting hints if ingestion failed (most early failures are bucket permissions, file format, or an expired +secret). + +## Removing a Knowledge Base + +```bash +agentcore remove knowledge-base --name docs +``` + +The remove preview shows everything that will be cleaned up: + +- the KB and its data sources from `knowledgeBases[]`, +- the per-KB Retrieve target on the wired gateway, and +- the entry from the gateway's shared `agentic-retrieve` target — and the agentic target itself if this was the last KB + on the gateway. + +`agentcore deploy` after the remove cleanly tears down the CFN resources. + +## Configuration Reference + +In `agentcore.json`: + +```json +{ + "knowledgeBases": [ + { + "name": "docs", + "description": "Product manuals", + "gateway": "docs-gw", + "dataSources": [ + { "type": "S3", "uri": "s3://corpus-bucket/manuals/" }, + { "type": "WEB", "connectorConfigFile": "app/docs/web-crawler.json" } + ] + } + ], + "agentCoreGateways": [ + { + "name": "docs-gw", + "targets": [ + { + "name": "docs", + "targetType": "connector", + "connectorId": "bedrock-knowledge-bases", + "knowledgeBaseId": "docs" + }, + { + "name": "docs-gw-agentic", + "targetType": "connector", + "connectorId": "bedrock-agentic-retrieve", + "knowledgeBaseIds": ["docs"] + } + ] + } + ] +} +``` + +`knowledgeBaseId` on a connector target accepts either a project KB name (an entry in `knowledgeBases[]`) or a literal +10-character external KB ID. The two formats can never collide because real Bedrock KB IDs are 10 uppercase alphanumeric +chars and project names start with a letter and may include dashes/underscores. + +After deploy, `agentcore/.cli/deployed-state.json` carries the resolved Bedrock KB ID and per-data-source IDs: + +```json +{ + "targets": { + "default": { + "resources": { + "knowledgeBases": { + "docs": { + "knowledgeBaseId": "ABCDEFGHIJ", + "knowledgeBaseArn": "arn:aws:bedrock:us-west-2:111122223333:knowledge-base/ABCDEFGHIJ", + "dataSources": { + "s3://corpus-bucket/manuals/": "ABC1234567" + } + } + } + } + } + } +} +``` + +## Common Issues + +**"Gateway 'X' not found in agentcore.json"** — add the gateway first with `agentcore add gateway --name X` before +attaching the KB to it. The CLI never auto-creates a gateway from `add knowledge-base` non-interactively. + +**Ingestion shows `FAILED` immediately after deploy** — for S3 sources, most early failures are: the bucket doesn't +exist, the bucket is in a different AWS account, the file format is unsupported, or the file size exceeds 50 MB. +`agentcore status --type knowledge-base --name ` shows the troubleshooting hints inline. + +**DataSource itself in `FAILED` state right after deploy (non-S3 connectors)** — Bedrock validates the +`connectorParameters` you wrote in the JSON file and rejects bad enum values, missing fields, or unreachable seed URLs. +Surface the exact reason with: + +```bash +aws bedrock-agent get-data-source \ + --knowledge-base-id \ + --data-source-id \ + --region us-west-2 \ + --query 'dataSource.failureReasons' +``` + +The most common Web Crawler trip-up is `crawlConfiguration.syncScope` — only `PATH_SPECIFIC`, `SUB_DOMAINS`, +`ALL_DOMAINS`, and `DOMAINS_ONLY` are accepted. See +[`docs/connector-config-templates/README.md`](connector-config-templates/README.md) for the full list of value gotchas. +Edit `app//.json`, then `agentcore deploy` to update the DataSource and re-trigger ingestion. + +**"Duplicate data source in this invocation"** — you passed the same `--source` URI twice on one call. Drop the +duplicate. + +**"Connector config files X and Y would both be stored as 'app//'"** — two of your connector configs share +a filename. Rename one before passing both. diff --git a/docs/recommendations.md b/docs/recommendations.md index c5a5c4ac3..b6d25f9c7 100644 --- a/docs/recommendations.md +++ b/docs/recommendations.md @@ -1,4 +1,4 @@ -# Recommendations [preview] +# Recommendations Recommendations optimize your agent's system prompt or tool descriptions using historical traces as signal. The recommendation service analyzes how your agent performed, then produces an improved version scored by an evaluator. @@ -93,16 +93,36 @@ agentcore run recommendation ... --session-id agentcore run recommendation ... --spans-file ./traces.json ``` +## Encrypting Results with KMS + +By default, recommendation results are encrypted with an AWS-managed key. To encrypt them with your own customer managed +key (CMK), pass its ARN with `--kms-key`: + +```bash +agentcore run recommendation \ + -t system-prompt \ + -r MyAgent \ + -e Builtin.Correctness \ + --inline "You are a helpful assistant" \ + --kms-key arn:aws:kms:us-west-2:111122223333:key/12345678-1234-1234-1234-123456789012 +``` + +The key must be in the same region as the recommendation, and the calling principal (and the AgentCore service) must +have `kms:Encrypt`/`kms:GenerateDataKey` permissions on it. Omit the flag to use the AWS-managed key. + ## JSON Output ```bash agentcore run recommendation -r MyAgent -e Builtin.Helpfulness --type system-prompt --inline "..." --json ``` -Returns `recommendationId`, `status`, and `result` with `systemPromptRecommendationResult.recommendedSystemPrompt` or +Recommendations are fire-and-forget jobs: `run recommendation` returns `recommendationId` and an initial `status` +(`PENDING`/`IN_PROGRESS`) — the optimized `result` is **not** available immediately. Pass `--wait` to block until the +job finishes, or check later with `agentcore view recommendation --json`, which returns the completed `result` with +`systemPromptRecommendationResult.recommendedSystemPrompt` (and `explanation`) or `toolDescriptionRecommendationResult.tools`. -When using `--bundle-name`, the result also includes `configurationBundle.versionId` — the new bundle version. +When using `--bundle-name`, the completed result also includes `configurationBundle.versionId` — the new bundle version. ## End-to-End Workflow: Recommendation → Config Bundle → Invoke @@ -136,11 +156,11 @@ When using `--bundle-name`, the result also includes `configurationBundle.versio ## Viewing History -Results are saved in `.cli/recommendations/`. View past runs via the TUI: +Job records are saved in `.cli/jobs/recommendations/`. View past runs via the TUI: ```bash agentcore -# Navigate to: Recommendations → History +# Navigate to: Recommendations → History (or View → Recommendation) ``` ## TUI Wizard diff --git a/e2e-tests/README.md b/e2e-tests/README.md index 45090b613..3cdfaed5f 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -111,7 +111,6 @@ Framework/model combination tests: `{framework}-{model}.test.ts` Feature lifecycle tests: describe what the test exercises end-to-end -- `ab-test-target-based.test.ts` - `dev-lifecycle.test.ts` - `evals-lifecycle.test.ts` diff --git a/e2e-tests/ab-test-config-bundle.test.ts b/e2e-tests/ab-test-config-bundle.test.ts deleted file mode 100644 index cec0a9cc0..000000000 --- a/e2e-tests/ab-test-config-bundle.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { parseJsonOutput, retry } from '../src/test-utils/index.js'; -import { - baseCanRun, - hasAws, - installCdkTarball, - runAgentCoreCLI, - teardownE2EProject, - writeAwsTargets, -} from './e2e-helper.js'; -import { randomUUID } from 'node:crypto'; -import { mkdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -const canRun = baseCanRun && hasAws; - -describe.sequential('e2e: config-bundle AB test lifecycle', () => { - let testDir: string; - let projectPath: string; - const agentName = `E2eCfgAB${String(Date.now()).slice(-8)}`; - const abTestName = 'ConfigBundleABTest'; - const evalName = 'BundleEvaluator'; - const onlineEvalName = 'BundleOnlineEval'; - - beforeAll(async () => { - if (!canRun) return; - - testDir = join(tmpdir(), `agentcore-e2e-cfg-ab-${randomUUID()}`); - await mkdir(testDir, { recursive: true }); - - const result = await runAgentCoreCLI( - [ - 'create', - '--name', - agentName, - '--language', - 'Python', - '--framework', - 'Strands', - '--model-provider', - 'Bedrock', - '--memory', - 'none', - '--json', - ], - testDir - ); - expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0); - projectPath = (parseJsonOutput(result.stdout) as { projectPath: string }).projectPath; - - await writeAwsTargets(projectPath); - installCdkTarball(projectPath); - }, 300000); - - afterAll(async () => { - if (projectPath && hasAws) { - await teardownE2EProject(projectPath, agentName, 'Bedrock'); - } - if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); - }, 600000); - - const run = (args: string[]) => runAgentCoreCLI(args, projectPath); - - it.skipIf(!canRun)( - 'adds evaluator and online eval config', - async () => { - let result = await run([ - 'add', - 'evaluator', - '--name', - evalName, - '--level', - 'SESSION', - '--model', - 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', - '--instructions', - 'Evaluate session quality. Context: {context}', - '--json', - ]); - expect(result.exitCode, `Add evaluator failed: ${result.stdout}`).toBe(0); - - result = await run([ - 'add', - 'online-eval', - '--name', - onlineEvalName, - '--runtime', - agentName, - '--evaluator', - evalName, - '--sampling-rate', - '100', - '--enable-on-create', - '--json', - ]); - expect(result.exitCode, `Add online-eval failed: ${result.stdout}`).toBe(0); - }, - 60000 - ); - - it.skipIf(!canRun)( - 'deploys agent before AB test (needed for config bundles)', - async () => { - await retry( - async () => { - const result = await run(['deploy', '--yes', '--json']); - expect(result.exitCode, `Initial deploy failed`).toBe(0); - const json = parseJsonOutput(result.stdout) as { success: boolean }; - expect(json.success).toBe(true); - }, - 2, - 30000 - ); - }, - 600000 - ); - - it.skipIf(!canRun)( - 'adds config-bundle AB test with 90/10 split', - async () => { - // Use placeholder bundle ARNs that satisfy the service format constraints. - // Real config bundles would be created separately; these test the AB test wiring. - const region = process.env.AWS_REGION ?? 'us-east-1'; - const account = process.env.AWS_ACCOUNT_ID ?? '000000000000'; - const controlBundle = `arn:aws:bedrock-agentcore:${region}:${account}:configuration-bundle/control-bundle-AbCdEfGhIj`; - const treatmentBundle = `arn:aws:bedrock-agentcore:${region}:${account}:configuration-bundle/treatment-bundle-AbCdEfGhIj`; - - const result = await run([ - 'add', - 'ab-test', - '--mode', - 'config-bundle', - '--name', - abTestName, - '--runtime', - agentName, - '--control-bundle', - controlBundle, - '--control-version', - '00000000-0000-0000-0000-000000000001', - '--treatment-bundle', - treatmentBundle, - '--treatment-version', - '00000000-0000-0000-0000-000000000002', - '--control-weight', - '90', - '--treatment-weight', - '10', - '--online-eval', - onlineEvalName, - '--json', - ]); - expect(result.exitCode, `Add AB test failed: ${result.stdout}`).toBe(0); - const json = parseJsonOutput(result.stdout) as { success: boolean; abTestName: string }; - expect(json.success).toBe(true); - expect(json.abTestName).toBe(abTestName); - }, - 60000 - ); - - it.skipIf(!canRun)( - 'status shows AB test in config', - async () => { - const result = await run(['status', '--json']); - expect(result.exitCode, `Status failed: ${result.stderr}`).toBe(0); - - const json = parseJsonOutput(result.stdout) as { - success: boolean; - resources: { resourceType: string; name: string; deploymentState: string }[]; - }; - expect(json.success).toBe(true); - - // Agent should be deployed - const agent = json.resources.find(r => r.resourceType === 'agent' && r.name === agentName); - expect(agent, `Agent "${agentName}" should appear in status`).toBeDefined(); - expect(agent!.deploymentState).toBe('deployed'); - }, - 120000 - ); - - it.skipIf(!canRun)( - 'invokes the deployed agent', - async () => { - await retry( - async () => { - const result = await run(['invoke', '--prompt', 'Say hello', '--runtime', agentName, '--json']); - expect(result.exitCode, `Invoke failed: ${result.stderr}`).toBe(0); - const json = parseJsonOutput(result.stdout) as { success: boolean }; - expect(json.success).toBe(true); - }, - 3, - 15000 - ); - }, - 180000 - ); - - it.skipIf(!canRun)( - 'removes config-bundle AB test', - async () => { - const result = await run(['remove', 'ab-test', '--name', abTestName, '--json']); - expect(result.exitCode, `Remove failed: ${result.stderr}`).toBe(0); - const json = parseJsonOutput(result.stdout) as Record; - expect(json).toHaveProperty('success', true); - }, - 60000 - ); -}); diff --git a/e2e-tests/ab-test-target-based.test.ts b/e2e-tests/ab-test-target-based.test.ts deleted file mode 100644 index 274ee447a..000000000 --- a/e2e-tests/ab-test-target-based.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { parseJsonOutput, retry } from '../src/test-utils/index.js'; -import { - baseCanRun, - hasAws, - installCdkTarball, - runAgentCoreCLI, - teardownE2EProject, - writeAwsTargets, -} from './e2e-helper.js'; -import { randomUUID } from 'node:crypto'; -import { mkdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -const canRun = baseCanRun && hasAws; - -describe.sequential('e2e: target-based AB test lifecycle', () => { - let testDir: string; - let projectPath: string; - const agentName = `E2eTargAB${String(Date.now()).slice(-8)}`; - const abTestName = 'TargetABTest'; - const evalName = 'ABTestEvaluator'; - const controlEvalName = 'ControlEvalConfig'; - const treatmentEvalName = 'TreatmentEvalConfig'; - - beforeAll(async () => { - if (!canRun) return; - - testDir = join(tmpdir(), `agentcore-e2e-target-ab-${randomUUID()}`); - await mkdir(testDir, { recursive: true }); - - const result = await runAgentCoreCLI( - [ - 'create', - '--name', - agentName, - '--language', - 'Python', - '--framework', - 'Strands', - '--model-provider', - 'Bedrock', - '--memory', - 'none', - '--json', - ], - testDir - ); - expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0); - projectPath = (parseJsonOutput(result.stdout) as { projectPath: string }).projectPath; - - await writeAwsTargets(projectPath); - installCdkTarball(projectPath); - }, 300000); - - afterAll(async () => { - if (projectPath && hasAws) { - await teardownE2EProject(projectPath, agentName, 'Bedrock'); - } - if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); - }, 600000); - - const run = (args: string[]) => runAgentCoreCLI(args, projectPath); - - it.skipIf(!canRun)( - 'adds runtime endpoints (prod v1, staging v1)', - async () => { - let result = await run([ - 'add', - 'runtime-endpoint', - '--runtime', - agentName, - '--endpoint', - 'prod', - '--version', - '1', - '--json', - ]); - expect(result.exitCode, `Add prod endpoint failed: ${result.stdout}`).toBe(0); - - result = await run([ - 'add', - 'runtime-endpoint', - '--runtime', - agentName, - '--endpoint', - 'staging', - '--version', - '1', - '--json', - ]); - expect(result.exitCode, `Add staging endpoint failed: ${result.stdout}`).toBe(0); - }, - 60000 - ); - - it.skipIf(!canRun)( - 'adds evaluator and per-variant online eval configs', - async () => { - let result = await run([ - 'add', - 'evaluator', - '--name', - evalName, - '--level', - 'SESSION', - '--model', - 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', - '--instructions', - 'Evaluate quality. Context: {context}', - '--json', - ]); - expect(result.exitCode, `Add evaluator failed: ${result.stdout}`).toBe(0); - - result = await run([ - 'add', - 'online-eval', - '--name', - controlEvalName, - '--runtime', - agentName, - '--evaluator', - evalName, - '--sampling-rate', - '100', - '--endpoint', - 'prod', - '--enable-on-create', - '--json', - ]); - expect(result.exitCode, `Add control online-eval failed: ${result.stdout}`).toBe(0); - - result = await run([ - 'add', - 'online-eval', - '--name', - treatmentEvalName, - '--runtime', - agentName, - '--evaluator', - evalName, - '--sampling-rate', - '100', - '--endpoint', - 'staging', - '--enable-on-create', - '--json', - ]); - expect(result.exitCode, `Add treatment online-eval failed: ${result.stdout}`).toBe(0); - }, - 60000 - ); - - it.skipIf(!canRun)( - 'adds target-based AB test with 90/10 split', - async () => { - const result = await run([ - 'add', - 'ab-test', - '--mode', - 'target-based', - '--name', - abTestName, - '--runtime', - agentName, - '--gateway', - `${abTestName}-gw`, - '--control-endpoint', - 'prod', - '--treatment-endpoint', - 'staging', - '--control-weight', - '90', - '--treatment-weight', - '10', - '--control-online-eval', - controlEvalName, - '--treatment-online-eval', - treatmentEvalName, - '--enable', - '--json', - ]); - expect(result.exitCode, `Add AB test failed: ${result.stdout}`).toBe(0); - const json = parseJsonOutput(result.stdout) as { success: boolean; abTestName: string }; - expect(json.success).toBe(true); - expect(json.abTestName).toBe(abTestName); - }, - 60000 - ); - - it.skipIf(!canRun)( - 'deploys project (creates gateway, targets, AB test, eval configs)', - async () => { - await retry( - async () => { - const result = await run(['deploy', '--yes', '--json']); - expect(result.exitCode, `Deploy failed (stderr: ${result.stderr})`).toBe(0); - const json = parseJsonOutput(result.stdout) as { success: boolean }; - expect(json.success).toBe(true); - }, - 2, - 30000 - ); - }, - 600000 - ); - - it.skipIf(!canRun)( - 'AB test reaches RUNNING status after deploy', - async () => { - await retry( - async () => { - const result = await run(['ab-test', abTestName, '--json']); - expect(result.exitCode, `ab-test lookup failed: ${result.stdout} ${result.stderr}`).toBe(0); - const json = parseJsonOutput(result.stdout) as { executionStatus: string }; - expect(json.executionStatus, 'AB test should be RUNNING after deploy').toBe('RUNNING'); - }, - 12, - 15000 - ); - }, - 300000 - ); - - it.skipIf(!canRun)( - 'status shows all resources deployed', - async () => { - await retry( - async () => { - const result = await run(['status', '--json']); - expect(result.exitCode, `Status failed: ${result.stderr}`).toBe(0); - - const json = parseJsonOutput(result.stdout) as { - success: boolean; - resources: { resourceType: string; name: string; deploymentState: string; invocationUrl?: string }[]; - }; - expect(json.success).toBe(true); - - // Agent should be deployed - const agent = json.resources.find(r => r.resourceType === 'agent' && r.name === agentName); - expect(agent, `Agent "${agentName}" should appear in status`).toBeDefined(); - expect(agent!.deploymentState).toBe('deployed'); - - // AB test should be deployed (HTTP gateways are not surfaced as top-level status resources) - const abTest = json.resources.find(r => r.resourceType === 'ab-test' && r.name === abTestName); - expect(abTest, `AB test "${abTestName}" should appear in status`).toBeDefined(); - expect(abTest!.deploymentState).toBe('deployed'); - // invocationUrl proves the HTTP gateway was deployed and wired up correctly - expect(abTest!.invocationUrl, 'AB test should have a gateway invocation URL').toBeTruthy(); - }, - 3, - 15000 - ); - }, - 120000 - ); - - it.skipIf(!canRun)( - 'pauses AB test', - async () => { - await retry( - async () => { - const result = await run(['pause', 'ab-test', abTestName, '--json']); - expect(result.exitCode, `Pause failed: ${result.stderr}`).toBe(0); - const json = parseJsonOutput(result.stdout) as Record; - expect(json).toHaveProperty('success', true); - expect(json).toHaveProperty('executionStatus', 'PAUSED'); - }, - 3, - 10000 - ); - }, - 120000 - ); - - it.skipIf(!canRun)( - 'resumes AB test', - async () => { - await retry( - async () => { - const result = await run(['resume', 'ab-test', abTestName, '--json']); - expect(result.exitCode, `Resume failed: ${result.stderr}`).toBe(0); - const json = parseJsonOutput(result.stdout) as Record; - expect(json).toHaveProperty('success', true); - expect(json).toHaveProperty('executionStatus', 'RUNNING'); - }, - 3, - 10000 - ); - }, - 120000 - ); - - it.skipIf(!canRun)( - 'promotes AB test (updates agentcore.json)', - async () => { - const result = await run(['promote', 'ab-test', abTestName, '--json']); - expect(result.exitCode, `Promote failed: ${result.stdout} ${result.stderr}`).toBe(0); - const json = parseJsonOutput(result.stdout) as Record; - expect(json).toHaveProperty('success', true); - expect(json).toHaveProperty('promoted', true); - }, - 120000 - ); - - it.skipIf(!canRun)( - 'removes AB test from config', - async () => { - const result = await run(['remove', 'ab-test', '--name', abTestName, '--delete-gateway', '--json']); - expect(result.exitCode, `Remove failed: ${result.stderr}`).toBe(0); - const json = parseJsonOutput(result.stdout) as Record; - expect(json).toHaveProperty('success', true); - }, - 60000 - ); -}); diff --git a/e2e-tests/archive-lifecycle.test.ts b/e2e-tests/archive-lifecycle.test.ts index dbe0a053e..83b949a97 100644 --- a/e2e-tests/archive-lifecycle.test.ts +++ b/e2e-tests/archive-lifecycle.test.ts @@ -136,9 +136,9 @@ describe.sequential('e2e: archive command lifecycle', () => { ); const json = parseJsonOutput(result.stdout) as Record; expect(json).toHaveProperty('success', true); - expect(json.batchEvaluationId).toBeTruthy(); + expect(json.id).toBeTruthy(); expect(json.status).not.toBe('FAILED'); - batchEvaluationId = json.batchEvaluationId as string; + batchEvaluationId = json.id as string; }, 6, 15000 @@ -182,8 +182,8 @@ describe.sequential('e2e: archive command lifecycle', () => { expect(result.exitCode, `recommendation failed (stdout: ${result.stdout}, stderr: ${result.stderr})`).toBe(0); const json = parseJsonOutput(result.stdout) as Record; expect(json).toHaveProperty('success', true); - expect(json.recommendationId).toBeTruthy(); - recommendationId = json.recommendationId as string; + expect(json.id).toBeTruthy(); + recommendationId = json.id as string; }, 6, 30000 @@ -225,9 +225,7 @@ describe.sequential('e2e: archive command lifecycle', () => { const json = parseJsonOutput(result.stdout) as Record; expect(json).toHaveProperty('success', true); - expect(json.batchEvaluationId).toBe(batchEvaluationId); - expect(json).toHaveProperty('localCliHistoryDeleted', true); - expect(json.localDeleteWarning).toBeUndefined(); + expect(json.id).toBe(batchEvaluationId); }, 120000 ); @@ -291,9 +289,7 @@ describe.sequential('e2e: archive command lifecycle', () => { const json = parseJsonOutput(result.stdout) as Record; expect(json).toHaveProperty('success', true); - expect(json.recommendationId).toBe(recommendationId); - expect(json).toHaveProperty('localCliHistoryDeleted', true); - expect(json.localDeleteWarning).toBeUndefined(); + expect(json.id).toBe(recommendationId); }, 120000 ); @@ -307,18 +303,6 @@ describe.sequential('e2e: archive command lifecycle', () => { 30000 ); - it.skipIf(!canRun)( - 'recommendations history no longer includes the archived entry', - async () => { - const result = await run(['recommendations', 'history', '--json']); - expect(result.exitCode, `recommendations history failed: ${result.stderr}`).toBe(0); - const json = parseJsonOutput(result.stdout) as { recommendations: { recommendationId: string }[] }; - const ids = (json.recommendations ?? []).map(r => r.recommendationId); - expect(ids).not.toContain(recommendationId); - }, - 60000 - ); - it.skipIf(!canRun)( 'archiving the same recommendation again returns success false (already deleted)', async () => { diff --git a/e2e-tests/config-bundle-eval-rec.test.ts b/e2e-tests/config-bundle-eval-rec.test.ts index 01e3287bf..88c505dba 100644 --- a/e2e-tests/config-bundle-eval-rec.test.ts +++ b/e2e-tests/config-bundle-eval-rec.test.ts @@ -224,7 +224,7 @@ describe.sequential('e2e: config bundles, batch evaluation, and recommendations' it.skipIf(!canRun)( 'config-bundle versions lists the deployed version', async () => { - const result = await run(['config-bundle', 'versions', '--bundle', bundleName, '--json']); + const result = await run(['config-bundle', 'versions', '--name', bundleName, '--json']); expect(result.exitCode, `cb versions failed: ${result.stderr}`).toBe(0); const json = parseJsonOutput(result.stdout) as { @@ -243,7 +243,7 @@ describe.sequential('e2e: config bundles, batch evaluation, and recommendations' it.skipIf(!canRun)( 'config-bundle versions supports --branch filter', async () => { - const result = await run(['config-bundle', 'versions', '--bundle', bundleName, '--branch', 'mainline', '--json']); + const result = await run(['config-bundle', 'versions', '--name', bundleName, '--branch', 'mainline', '--json']); expect(result.exitCode, `cb versions --branch failed: ${result.stderr}`).toBe(0); const json = parseJsonOutput(result.stdout) as { @@ -302,7 +302,7 @@ describe.sequential('e2e: config bundles, batch evaluation, and recommendations' it.skipIf(!canRun)( 'config-bundle versions shows both versions after update', async () => { - const result = await run(['config-bundle', 'versions', '--bundle', bundleName, '--json']); + const result = await run(['config-bundle', 'versions', '--name', bundleName, '--json']); expect(result.exitCode, `cb versions failed: ${result.stderr}`).toBe(0); const json = parseJsonOutput(result.stdout) as { @@ -318,7 +318,7 @@ describe.sequential('e2e: config bundles, batch evaluation, and recommendations' 'config-bundle diff shows changes between versions', async () => { // Get the latest two versions - const versionsResult = await run(['config-bundle', 'versions', '--bundle', bundleName, '--json']); + const versionsResult = await run(['config-bundle', 'versions', '--name', bundleName, '--json']); const versionsJson = parseJsonOutput(versionsResult.stdout) as { versions: { versionId: string }[]; }; @@ -330,7 +330,7 @@ describe.sequential('e2e: config bundles, batch evaluation, and recommendations' const result = await run([ 'config-bundle', 'diff', - '--bundle', + '--name', bundleName, '--from', oldestVersion, @@ -375,7 +375,7 @@ describe.sequential('e2e: config bundles, batch evaluation, and recommendations' ); const json = parseJsonOutput(result.stdout) as Record; expect(json).toHaveProperty('success', true); - expect(json).toHaveProperty('batchEvaluationId'); + expect(json).toHaveProperty('id'); expect(json.status).toBeDefined(); expect(json.status).not.toBe('FAILED'); }, @@ -497,7 +497,7 @@ describe.sequential('e2e: config bundles, batch evaluation, and recommendations' expect(result.exitCode, `recommendation failed (stdout: ${result.stdout}, stderr: ${result.stderr})`).toBe(0); const json = parseJsonOutput(result.stdout) as Record; expect(json).toHaveProperty('success', true); - expect(json).toHaveProperty('recommendationId'); + expect(json).toHaveProperty('id'); expect(json.result).toBeDefined(); expect(json.result).not.toBe(''); expect(json.result).not.toBeNull(); @@ -534,7 +534,7 @@ describe.sequential('e2e: config bundles, batch evaluation, and recommendations' expect(result.exitCode, `recommendation from file failed: ${result.stdout}`).toBe(0); const json = parseJsonOutput(result.stdout) as Record; expect(json).toHaveProperty('success', true); - expect(json).toHaveProperty('recommendationId'); + expect(json).toHaveProperty('id'); }, 6, 30000 @@ -565,7 +565,7 @@ describe.sequential('e2e: config bundles, batch evaluation, and recommendations' expect(result.exitCode, `tool-desc recommendation failed: ${result.stdout}`).toBe(0); const json = parseJsonOutput(result.stdout) as Record; expect(json).toHaveProperty('success', true); - expect(json).toHaveProperty('recommendationId'); + expect(json).toHaveProperty('id'); }, 6, 30000 @@ -578,7 +578,7 @@ describe.sequential('e2e: config bundles, batch evaluation, and recommendations' 'runs recommendation with config bundle source via CLI', async () => { // Get the latest version ID for the bundle - const versionsResult = await run(['config-bundle', 'versions', '--bundle', bundleName, '--json']); + const versionsResult = await run(['config-bundle', 'versions', '--name', bundleName, '--json']); const versionsJson = parseJsonOutput(versionsResult.stdout) as { versions: { versionId: string }[]; }; @@ -607,7 +607,7 @@ describe.sequential('e2e: config bundles, batch evaluation, and recommendations' expect(result.exitCode, `bundle recommendation failed: ${result.stdout}`).toBe(0); const json = parseJsonOutput(result.stdout) as Record; expect(json).toHaveProperty('success', true); - expect(json).toHaveProperty('recommendationId'); + expect(json).toHaveProperty('id'); }, 6, 30000 diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 0f93f2857..3e02ba745 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -1,6 +1,8 @@ import { cleanupStaleCredentialProviders } from './utils/credential-provider-cleanup'; import { getLogger } from './utils/logger'; +import { cleanupStaleRecommendations } from './utils/recommendation-cleanup'; import { cleanUpOldStacks } from './utils/stack-cleanup'; +import { BedrockAgentCoreClient } from '@aws-sdk/client-bedrock-agentcore'; import { BedrockAgentCoreControlClient } from '@aws-sdk/client-bedrock-agentcore-control'; import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; import type { TestProject } from 'vitest/node'; @@ -47,6 +49,23 @@ export default async function setup(_project: TestProject): Promise<() => void> bedrockCPClient.destroy(); } + // Recommendations are capped at 5 active per account. Failed e2e runs leak ACTIVE + // recommendations that never reach a terminal state, so the next run 402s on every + // StartRecommendation across all shards. Reap leftover e2e recs before starting. + logger.info(`cleaning up stale active recommendations...`); + const bedrockDPClient = new BedrockAgentCoreClient({ region: region, maxAttempts: 10 }); + try { + await cleanupStaleRecommendations(bedrockDPClient, logger.child('recommendation-cleanup'), { + minAgeMs: 30 * 60 * 1000, + prefix: 'E2e', + }); + } catch (e) { + logger.error(String(e)); + logger.warn(`failed to clean up stale recommendations`); + } finally { + bedrockDPClient.destroy(); + } + logger.info(`setup finished in ${(Date.now() - startTime) / 1000} seconds`); return function teardown(): void { diff --git a/e2e-tests/guardrail-block.test.ts b/e2e-tests/guardrail-block.test.ts new file mode 100644 index 000000000..abd335bb1 --- /dev/null +++ b/e2e-tests/guardrail-block.test.ts @@ -0,0 +1,233 @@ +import { type RunResult, hasAwsCredentials, parseJsonOutput, prereqs, retry } from '../src/test-utils/index.js'; +import { installCdkTarball, runAgentCoreCLI, writeAwsTargets } from './e2e-helper.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const hasAws = hasAwsCredentials(); + +// The AWS::BedrockAgentCore::Policy CFN resource type is not yet generally +// released, so `agentcore deploy` cannot synth/provision the policy and this +// end-to-end suite cannot pass. Skip the whole suite until the resource type +// is released, then drop SUITE_DISABLED to re-enable. +const SUITE_DISABLED = true; +const canRun = !SUITE_DISABLED && prereqs.npm && prereqs.git && prereqs.uv && hasAws; + +/** + * e2e: policy engine blocks a gateway invoke via CFN-deployed forbid policy. + * + * This test manually wires what the (removed) "secure mode" used to do automatically: + * 1. create a Strands/Bedrock project (agent runtime) + * 2. add a Cedar policy engine + * 3. add a gateway referencing the engine in ENFORCE mode (authorizer AWS_IAM) + * 4. add an http-runtime gateway target pointing at the agent runtime + * 5. add a blanket forbid policy scoped to AgentCore::Gateway + * 6. deploy via CFN (runtime + gateway + engine + policy all provisioned) + * 7. invoke through the gateway — assert the request is BLOCKED (403) + * + * The blanket `forbid(principal, action, resource is AgentCore::Gateway);` policy blocks ALL + * requests through the gateway, proving the policy engine ENFORCE mechanism works end-to-end. + */ +describe.skipIf(!canRun).sequential('e2e: policy engine blocks gateway invoke', () => { + const suffix = Date.now().toString().slice(-8); + const agentName = `E2eGrd${suffix}`; + const gatewayName = 'grdgw'; + const targetName = 'grdtarget'; + const engineName = 'grdengine'; + const policyName = 'denyall'; + + let projectPath: string; + let testDir: string; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-guardrail-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const createResult = await runAgentCoreCLI( + [ + 'create', + '--name', + agentName, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir + ); + expect(createResult.exitCode, `Create failed: ${createResult.stderr}`).toBe(0); + projectPath = (parseJsonOutput(createResult.stdout) as { projectPath: string }).projectPath; + + await writeAwsTargets(projectPath); + installCdkTarball(projectPath); + }, 600_000); + + afterAll(async () => { + if (projectPath && hasAws) { + await runAgentCoreCLI(['remove', 'all', '--json'], projectPath); + const deployResult = await runAgentCoreCLI(['deploy', '--yes', '--json'], projectPath); + if (deployResult.exitCode !== 0) { + console.warn('Teardown deploy failed:', deployResult.stderr); + } + } + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600_000); + + const run = (args: string[]): Promise => runAgentCoreCLI(args, projectPath); + + const assertSuccess = (result: RunResult, label: string): void => { + expect(result.exitCode, `${label} failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success, `${label} should report success`).toBe(true); + }; + + // ── Manual wiring (the steps secure mode used to perform) ───────────── + + it.skipIf(!canRun)( + 'adds a policy engine', + async () => { + const result = await run(['add', 'policy-engine', '--name', engineName, '--json']); + assertSuccess(result, 'add policy-engine'); + }, + 60_000 + ); + + it.skipIf(!canRun)( + 'adds a gateway referencing the policy engine in ENFORCE mode', + async () => { + const result = await run([ + 'add', + 'gateway', + '--name', + gatewayName, + '--protocol-type', + 'None', + '--authorizer-type', + 'AWS_IAM', + '--policy-engine', + engineName, + '--policy-engine-mode', + 'ENFORCE', + '--json', + ]); + assertSuccess(result, 'add gateway'); + }, + 60_000 + ); + + it.skipIf(!canRun)( + 'adds an http-runtime target pointing at the agent runtime', + async () => { + const result = await run([ + 'add', + 'gateway-target', + '--name', + targetName, + '--gateway', + gatewayName, + '--type', + 'http-runtime', + '--runtime', + agentName, + '--json', + ]); + assertSuccess(result, 'add gateway-target'); + }, + 60_000 + ); + + it.skipIf(!canRun)( + 'adds a forbid-all policy scoped to AgentCore::Gateway', + async () => { + const result = await run([ + 'add', + 'policy', + '--name', + policyName, + '--engine', + engineName, + '--statement', + 'forbid(principal, action, resource is AgentCore::Gateway);', + '--validation-mode', + 'IGNORE_ALL_FINDINGS', + '--json', + ]); + assertSuccess(result, 'add policy'); + }, + 60_000 + ); + + // ── Deploy via CFN ──────────────────────────────────────────────────── + + it.skipIf(!canRun)( + 'deploys runtime + gateway + policy engine + policy via CFN', + async () => { + await retry( + async () => { + const result = await run(['deploy', '--yes', '--json']); + if (result.exitCode !== 0) { + console.log('Deploy stdout:', result.stdout); + console.log('Deploy stderr:', result.stderr); + } + expect(result.exitCode, `Deploy failed (stderr: ${result.stderr})`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success, 'Deploy should report success').toBe(true); + }, + 2, + 30_000 + ); + + // Confirm the gateway is deployed + const statePath = join(projectPath, 'agentcore', '.cli', 'deployed-state.json'); + const state = JSON.parse(await readFile(statePath, 'utf-8')) as { + targets: Record } }>; + }; + const gateways = Object.values(state.targets).flatMap(t => Object.values(t.resources?.gateways ?? {})); + expect(gateways.length, 'Gateway should be present in deployed state').toBeGreaterThan(0); + expect(gateways[0]!.gatewayId, 'Gateway should have an ID').toBeTruthy(); + }, + 600_000 + ); + + // ── Invoke through the gateway ────────────────────────────────────────── + + it.skipIf(!canRun)( + 'invoke through the gateway is blocked by the forbid-all policy', + async () => { + await retry( + async () => { + const result = await run([ + 'invoke', + '--gateway', + gatewayName, + '--gateway-target-name', + targetName, + '--prompt', + '{"message": "hello"}', + '--json', + ]); + + console.log('Policy-blocked invoke stdout:', result.stdout); + console.log('Policy-blocked invoke stderr:', result.stderr); + + const json = parseJsonOutput(result.stdout) as { success: boolean; error?: string }; + expect(json.success, `Invoke should be blocked but got: ${JSON.stringify(json)}`).toBe(false); + expect(json.error, 'Block error message should be present').toBeTruthy(); + expect(json.error!, `Error should indicate policy denial, got: ${json.error}`).toMatch(/denied|policy|403/i); + }, + 3, + 15_000 + ); + }, + 180_000 + ); +}); diff --git a/e2e-tests/utils/recommendation-cleanup.ts b/e2e-tests/utils/recommendation-cleanup.ts new file mode 100644 index 000000000..ab8f5b4b4 --- /dev/null +++ b/e2e-tests/utils/recommendation-cleanup.ts @@ -0,0 +1,62 @@ +import type { Logger } from './logger'; +import { + type BedrockAgentCoreClient, + DeleteRecommendationCommand, + RecommendationStatus, + type RecommendationSummary, + paginateListRecommendations, +} from '@aws-sdk/client-bedrock-agentcore'; + +const ACTIVE_STATUSES: ReadonlySet = new Set([ + RecommendationStatus.PENDING, + RecommendationStatus.IN_PROGRESS, +]); + +async function deleteRecommendation( + client: BedrockAgentCoreClient, + logger: Logger, + recommendationId: string, + name: string +): Promise { + try { + await client.send(new DeleteRecommendationCommand({ recommendationId })); + logger.info(`Deleted stale recommendation: ${name} (${recommendationId})`); + } catch (error) { + const err = error as Error; + logger.warn(`Failed to delete recommendation ${name} (${recommendationId}): ${err.name}:${err.message}`); + } +} + +/** + * Delete e2e recommendations that are still active beyond `minAgeMs` and match `prefix`. + * + * The recommendation service caps active recommendations at 5/account. Failed e2e runs + * can leak ACTIVE recommendations that never reach a terminal state, exhausting the slots + * and causing every subsequent StartRecommendation call to 402 across all shards. + * + * Recommendation has no Stop API — DeleteRecommendation is the cancel. + */ +export async function cleanupStaleRecommendations( + client: BedrockAgentCoreClient, + logger: Logger, + options: { + minAgeMs: number; + prefix: string; + } +): Promise { + const cutoff = new Date(Date.now() - options.minAgeMs); + + for await (const page of paginateListRecommendations({ client }, {})) { + const summaries: RecommendationSummary[] = page.recommendationSummaries ?? []; + const stale = summaries.filter( + r => + r.status !== undefined && + ACTIVE_STATUSES.has(r.status) && + r.name?.startsWith(options.prefix) && + r.createdAt !== undefined && + r.createdAt < cutoff + ); + + await Promise.all(stale.map(r => deleteRecommendation(client, logger, r.recommendationId!, r.name!))); + } +} diff --git a/integ-tests/add-remove-ab-test-target-based.test.ts b/integ-tests/add-remove-ab-test-target-based.test.ts deleted file mode 100644 index 8a77b1f06..000000000 --- a/integ-tests/add-remove-ab-test-target-based.test.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { - type TestProject, - createTestProject, - parseJsonOutput, - readProjectConfig, - runCLI, -} from '../src/test-utils/index.js'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -async function runSuccess(args: string[], cwd: string) { - const result = await runCLI(args, cwd); - expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); - const json: unknown = parseJsonOutput(result.stdout); - expect(json).toHaveProperty('success', true); - return json as Record; -} - -async function runFailure(args: string[], cwd: string) { - const result = await runCLI(args, cwd); - expect(result.exitCode).toBe(1); - const json: unknown = parseJsonOutput(result.stdout); - expect(json).toHaveProperty('success', false); - expect(json).toHaveProperty('error'); - return json as Record; -} - -describe('integration: add and remove target-based ab-test', () => { - let project: TestProject; - const gatewayName = 'my-test-gw'; - - beforeAll(async () => { - project = await createTestProject({ - name: 'TargetABTest', - language: 'Python', - framework: 'Strands', - modelProvider: 'Bedrock', - memory: 'none', - }); - - // Add runtime endpoints (prod and staging) for the agent - await runSuccess( - ['add', 'runtime-endpoint', '--runtime', project.agentName, '--endpoint', 'prod', '--version', '1', '--json'], - project.projectPath - ); - await runSuccess( - ['add', 'runtime-endpoint', '--runtime', project.agentName, '--endpoint', 'staging', '--version', '1', '--json'], - project.projectPath - ); - - // Add an evaluator and two online eval configs (one per variant) - await runSuccess( - [ - 'add', - 'evaluator', - '--name', - 'TestEval', - '--level', - 'SESSION', - '--model', - 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', - '--instructions', - 'Evaluate quality. Context: {context}', - '--json', - ], - project.projectPath - ); - await runSuccess( - [ - 'add', - 'online-eval', - '--name', - 'ControlEval', - '--runtime', - project.agentName, - '--evaluator', - 'TestEval', - '--sampling-rate', - '100', - '--endpoint', - 'prod', - '--json', - ], - project.projectPath - ); - await runSuccess( - [ - 'add', - 'online-eval', - '--name', - 'TreatmentEval', - '--runtime', - project.agentName, - '--evaluator', - 'TestEval', - '--sampling-rate', - '100', - '--endpoint', - 'staging', - '--json', - ], - project.projectPath - ); - }, 120000); - - afterAll(async () => { - await project.cleanup(); - }); - - it('adds target-based AB test with --control-endpoint and --treatment-endpoint', async () => { - const json = await runSuccess( - [ - 'add', - 'ab-test', - '--mode', - 'target-based', - '--name', - 'TargetTest1', - '--runtime', - project.agentName, - '--gateway', - gatewayName, - '--control-endpoint', - 'prod', - '--treatment-endpoint', - 'staging', - '--control-weight', - '90', - '--treatment-weight', - '10', - '--control-online-eval', - 'ControlEval', - '--treatment-online-eval', - 'TreatmentEval', - '--json', - ], - project.projectPath - ); - - expect(json.abTestName).toBe('TargetTest1'); - - // Verify agentcore.json has correct mode, targets, gateway auto-created - const spec = await readProjectConfig(project.projectPath); - const abTest = spec.abTests?.find((t: { name: string }) => t.name === 'TargetTest1'); - expect(abTest).toBeDefined(); - expect(abTest!.mode).toBe('target-based'); - expect(abTest!.variants).toHaveLength(2); - expect(abTest!.variants[0]!.name).toBe('C'); - expect(abTest!.variants[0]!.weight).toBe(90); - expect(abTest!.variants[0]!.variantConfiguration.target).toBeDefined(); - expect(abTest!.variants[0]!.variantConfiguration.target!.targetName).toBe(`${project.agentName}-prod`); - expect(abTest!.variants[1]!.name).toBe('T1'); - expect(abTest!.variants[1]!.weight).toBe(10); - expect(abTest!.variants[1]!.variantConfiguration.target!.targetName).toBe(`${project.agentName}-staging`); - expect(abTest!.gatewayRef).toBe(`{{gateway:${gatewayName}}}`); - - // Verify gateway was auto-created with targets - const gw = spec.httpGateways?.find((g: { name: string }) => g.name === gatewayName); - expect(gw, 'HTTP gateway should have been auto-created').toBeDefined(); - expect(gw!.targets).toBeDefined(); - expect(gw!.targets!.length).toBeGreaterThanOrEqual(2); - - const controlTarget = gw!.targets!.find((t: { name: string }) => t.name === `${project.agentName}-prod`); - expect(controlTarget).toBeDefined(); - expect(controlTarget!.qualifier).toBe('prod'); - - const treatmentTarget = gw!.targets!.find((t: { name: string }) => t.name === `${project.agentName}-staging`); - expect(treatmentTarget).toBeDefined(); - expect(treatmentTarget!.qualifier).toBe('staging'); - - // Verify per-variant evaluation config - const evalConfig = abTest!.evaluationConfig; - expect('perVariantOnlineEvaluationConfig' in evalConfig).toBe(true); - if ('perVariantOnlineEvaluationConfig' in evalConfig) { - expect(evalConfig.perVariantOnlineEvaluationConfig).toHaveLength(2); - const controlEval = evalConfig.perVariantOnlineEvaluationConfig.find( - (p: { treatmentName: string }) => p.treatmentName === 'C' - ); - expect(controlEval?.onlineEvaluationConfigArn).toBe('ControlEval'); - const treatmentEval = evalConfig.perVariantOnlineEvaluationConfig.find( - (p: { treatmentName: string }) => p.treatmentName === 'T1' - ); - expect(treatmentEval?.onlineEvaluationConfigArn).toBe('TreatmentEval'); - } - }); - - it('adds target-based AB test with existing gateway', async () => { - // TargetTest1 already created the gateway — reuse it - const json = await runSuccess( - [ - 'add', - 'ab-test', - '--mode', - 'target-based', - '--name', - 'TargetTest2', - '--runtime', - project.agentName, - '--gateway', - gatewayName, - '--control-endpoint', - 'prod', - '--treatment-endpoint', - 'staging', - '--control-weight', - '50', - '--treatment-weight', - '50', - '--control-online-eval', - 'ControlEval', - '--treatment-online-eval', - 'TreatmentEval', - '--json', - ], - project.projectPath - ); - - expect(json.abTestName).toBe('TargetTest2'); - - const spec = await readProjectConfig(project.projectPath); - // Gateway should still exist (reused, not duplicated) - const gateways = spec.httpGateways?.filter((g: { name: string }) => g.name === gatewayName); - expect(gateways).toHaveLength(1); - }); - - it('rejects duplicate AB test name', async () => { - const json = await runFailure( - [ - 'add', - 'ab-test', - '--mode', - 'target-based', - '--name', - 'TargetTest1', - '--runtime', - project.agentName, - '--gateway', - gatewayName, - '--control-endpoint', - 'prod', - '--treatment-endpoint', - 'staging', - '--control-weight', - '50', - '--treatment-weight', - '50', - '--control-online-eval', - 'ControlEval', - '--treatment-online-eval', - 'TreatmentEval', - '--json', - ], - project.projectPath - ); - - expect(json.error).toContain('already exists'); - }); - - it('rejects weights that do not sum to 100', async () => { - const json = await runFailure( - [ - 'add', - 'ab-test', - '--mode', - 'target-based', - '--name', - 'BadWeights', - '--runtime', - project.agentName, - '--gateway', - gatewayName, - '--control-endpoint', - 'prod', - '--treatment-endpoint', - 'staging', - '--control-weight', - '80', - '--treatment-weight', - '80', - '--control-online-eval', - 'ControlEval', - '--treatment-online-eval', - 'TreatmentEval', - '--json', - ], - project.projectPath - ); - - expect(json.error).toBeDefined(); - }); - - it('errors when --control-endpoint is missing in target-based mode', async () => { - const json = await runFailure( - [ - 'add', - 'ab-test', - '--mode', - 'target-based', - '--name', - 'MissingControl', - '--runtime', - project.agentName, - '--gateway', - gatewayName, - '--treatment-endpoint', - 'staging', - '--control-weight', - '50', - '--treatment-weight', - '50', - '--control-online-eval', - 'ControlEval', - '--treatment-online-eval', - 'TreatmentEval', - '--json', - ], - project.projectPath - ); - - expect(json.error).toContain('--control-endpoint'); - }); - - it('errors when --runtime is missing in target-based mode', async () => { - const json = await runFailure( - [ - 'add', - 'ab-test', - '--mode', - 'target-based', - '--name', - 'MissingRuntime', - '--gateway', - gatewayName, - '--control-endpoint', - 'prod', - '--treatment-endpoint', - 'staging', - '--control-weight', - '50', - '--treatment-weight', - '50', - '--control-online-eval', - 'ControlEval', - '--treatment-online-eval', - 'TreatmentEval', - '--json', - ], - project.projectPath - ); - - expect(json.error).toContain('--runtime'); - }); - - it('errors when endpoint does not exist on runtime', async () => { - const json = await runFailure( - [ - 'add', - 'ab-test', - '--mode', - 'target-based', - '--name', - 'BadEndpoint', - '--runtime', - project.agentName, - '--gateway', - gatewayName, - '--control-endpoint', - 'nonexistent', - '--treatment-endpoint', - 'staging', - '--control-weight', - '50', - '--treatment-weight', - '50', - '--control-online-eval', - 'ControlEval', - '--treatment-online-eval', - 'TreatmentEval', - '--json', - ], - project.projectPath - ); - - expect(json.error).toContain('nonexistent'); - }); - - it('deprecated --control-qualifier still works as alias for --control-endpoint', async () => { - const json = await runSuccess( - [ - 'add', - 'ab-test', - '--mode', - 'target-based', - '--name', - 'QualifierAlias', - '--runtime', - project.agentName, - '--gateway', - gatewayName, - '--control-qualifier', - 'prod', - '--treatment-qualifier', - 'staging', - '--control-weight', - '60', - '--treatment-weight', - '40', - '--control-online-eval', - 'ControlEval', - '--treatment-online-eval', - 'TreatmentEval', - '--json', - ], - project.projectPath - ); - - expect(json.abTestName).toBe('QualifierAlias'); - - const spec = await readProjectConfig(project.projectPath); - const abTest = spec.abTests?.find((t: { name: string }) => t.name === 'QualifierAlias'); - expect(abTest).toBeDefined(); - expect(abTest!.mode).toBe('target-based'); - expect(abTest!.variants[0]!.variantConfiguration.target!.targetName).toBe(`${project.agentName}-prod`); - expect(abTest!.variants[1]!.variantConfiguration.target!.targetName).toBe(`${project.agentName}-staging`); - }); - - it('removes target-based AB test without --delete-gateway', async () => { - const json = await runSuccess(['remove', 'ab-test', '--name', 'TargetTest2', '--json'], project.projectPath); - expect(json.success).toBe(true); - - // Verify removal from agentcore.json - const spec = await readProjectConfig(project.projectPath); - const abTest = spec.abTests?.find((t: { name: string }) => t.name === 'TargetTest2'); - expect(abTest).toBeUndefined(); - - // Gateway should still exist (other AB tests reference it) - const gw = spec.httpGateways?.find((g: { name: string }) => g.name === gatewayName); - expect(gw, 'Gateway should still exist when other AB tests reference it').toBeDefined(); - }); - - it('removes target-based AB test with --delete-gateway flag', async () => { - // First remove QualifierAlias so only TargetTest1 is left referencing the gateway - await runSuccess(['remove', 'ab-test', '--name', 'QualifierAlias', '--json'], project.projectPath); - - // Now remove TargetTest1 with --delete-gateway - const json = await runSuccess( - ['remove', 'ab-test', '--name', 'TargetTest1', '--delete-gateway', '--json'], - project.projectPath - ); - expect(json.success).toBe(true); - - // Verify gateway was also removed (no other AB tests reference it) - const spec = await readProjectConfig(project.projectPath); - const gw = spec.httpGateways?.find((g: { name: string }) => g.name === gatewayName); - expect(gw, 'Gateway should be removed with --delete-gateway when no other AB tests reference it').toBeUndefined(); - }); - - it('remove returns error for non-existent test', async () => { - const json = await runFailure(['remove', 'ab-test', '--name', 'DoesNotExist', '--json'], project.projectPath); - expect(json.error).toContain('not found'); - }); -}); diff --git a/integ-tests/add-remove-ab-test.test.ts b/integ-tests/add-remove-ab-test.test.ts deleted file mode 100644 index 1fd1aa7bc..000000000 --- a/integ-tests/add-remove-ab-test.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { - type TestProject, - createTestProject, - parseJsonOutput, - readProjectConfig, - runCLI, -} from '../src/test-utils/index.js'; -import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -const telemetry = createTelemetryHelper(); - -async function runSuccess(args: string[], cwd: string) { - const result = await runCLI(args, cwd); - expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); - const json: unknown = parseJsonOutput(result.stdout); - expect(json).toHaveProperty('success', true); - return json as Record; -} - -async function runFailure(args: string[], cwd: string) { - const result = await runCLI(args, cwd); - expect(result.exitCode).toBe(1); - const json: unknown = parseJsonOutput(result.stdout); - expect(json).toHaveProperty('success', false); - expect(json).toHaveProperty('error'); - return json as Record; -} - -describe('integration: add and remove ab-test', () => { - let project: TestProject; - - beforeAll(async () => { - project = await createTestProject({ - language: 'Python', - framework: 'Strands', - modelProvider: 'Bedrock', - memory: 'none', - }); - }); - - afterAll(async () => { - await project.cleanup(); - telemetry.destroy(); - }); - - it('requires --name for JSON mode', async () => { - const json = await runFailure(['add', 'ab-test', '--json'], project.projectPath); - expect(json.error).toContain('--name'); - }); - - it('requires --runtime when --name is provided', async () => { - const json = await runFailure(['add', 'ab-test', '--name', 'Test1', '--json'], project.projectPath); - expect(json.error).toContain('--runtime'); - }); - - it('adds ab-test with all required flags', async () => { - const json = await runSuccess( - [ - 'add', - 'ab-test', - '--name', - 'MyIntegTest', - '--runtime', - project.agentName, - '--control-bundle', - 'arn:bundle:control', - '--control-version', - 'v1', - '--treatment-bundle', - 'arn:bundle:treatment', - '--treatment-version', - 'v1', - '--control-weight', - '80', - '--treatment-weight', - '20', - '--online-eval', - 'arn:eval:config', - '--json', - ], - project.projectPath - ); - - expect(json.abTestName).toBe('MyIntegTest'); - - // Verify it's in agentcore.json with correct structure - const spec = await readProjectConfig(project.projectPath); - const abTest = spec.abTests?.find((t: { name: string }) => t.name === 'MyIntegTest'); - expect(abTest).toBeDefined(); - expect(abTest!.variants).toHaveLength(2); - expect(abTest!.variants[0]!.name).toBe('C'); - expect(abTest!.variants[0]!.weight).toBe(80); - expect(abTest!.variants[1]!.name).toBe('T1'); - expect(abTest!.variants[1]!.weight).toBe(20); - }); - - it('rejects duplicate AB test name', async () => { - const json = await runFailure( - [ - 'add', - 'ab-test', - '--name', - 'MyIntegTest', - '--runtime', - project.agentName, - '--control-bundle', - 'arn:cb', - '--control-version', - 'v1', - '--treatment-bundle', - 'arn:tb', - '--treatment-version', - 'v1', - '--control-weight', - '50', - '--treatment-weight', - '50', - '--online-eval', - 'arn:eval', - '--json', - ], - project.projectPath - ); - - expect(json.error).toContain('already exists'); - }); - - it('rejects weights that do not sum to 100', async () => { - const json = await runFailure( - [ - 'add', - 'ab-test', - '--name', - 'BadWeights', - '--runtime', - project.agentName, - '--control-bundle', - 'arn:cb', - '--control-version', - 'v1', - '--treatment-bundle', - 'arn:tb', - '--treatment-version', - 'v1', - '--control-weight', - '80', - '--treatment-weight', - '80', - '--online-eval', - 'arn:eval', - '--json', - ], - project.projectPath - ); - - expect(json.error).toBeDefined(); - }); - - it('removes ab-test', async () => { - const result = await runCLI(['remove', 'ab-test', '--name', 'MyIntegTest', '--json'], project.projectPath, { - env: telemetry.env, - }); - expect(result.exitCode).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(true); - - const spec = await readProjectConfig(project.projectPath); - const abTest = spec.abTests?.find((t: { name: string }) => t.name === 'MyIntegTest'); - expect(abTest).toBeUndefined(); - telemetry.assertMetricEmitted({ command: 'remove.ab-test', exit_reason: 'success' }); - }); - - it('remove returns error for non-existent test', async () => { - const result = await runCLI(['remove', 'ab-test', '--name', 'DoesNotExist', '--json'], project.projectPath, { - env: telemetry.env, - }); - expect(result.exitCode).toBe(1); - const json = JSON.parse(result.stdout); - expect(json.error).toContain('not found'); - telemetry.assertMetricEmitted({ command: 'remove.ab-test', exit_reason: 'failure' }); - }); -}); diff --git a/integ-tests/add-remove-config-bundle.test.ts b/integ-tests/add-remove-config-bundle.test.ts index c6c37c257..98c7d07a5 100644 --- a/integ-tests/add-remove-config-bundle.test.ts +++ b/integ-tests/add-remove-config-bundle.test.ts @@ -109,7 +109,9 @@ describe('integration: add and remove config-bundle', () => { const bundle = config.configBundles.find(b => b.name === 'FullOptsBundle'); expect(bundle).toBeDefined(); expect(bundle!.description).toBe('A bundle with all optional fields'); - expect(bundle!.branchName).toBe('feature-branch'); + // --branch is gated behind ENABLE_GATED_FEATURES; when off, silently defaults to mainline + const expectedBranch = process.env.ENABLE_GATED_FEATURES === '1' ? 'feature-branch' : 'mainline'; + expect(bundle!.branchName).toBe(expectedBranch); expect(bundle!.commitMessage).toBe('initial config'); }); diff --git a/integ-tests/add-remove-gateway.test.ts b/integ-tests/add-remove-gateway.test.ts index 8453c5e60..1e09bb0ba 100644 --- a/integ-tests/add-remove-gateway.test.ts +++ b/integ-tests/add-remove-gateway.test.ts @@ -27,7 +27,10 @@ describe('integration: add and remove gateway with external MCP server', () => { describe('gateway lifecycle', () => { it('adds a gateway', async () => { - const result = await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + const result = await runCLI( + ['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], + project.projectPath + ); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); const json = JSON.parse(result.stdout); @@ -136,7 +139,10 @@ describe('integration: add and remove gateway with OpenAPI schema target', () => describe('openApiSchema lifecycle', () => { it('adds a gateway', async () => { - const result = await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + const result = await runCLI( + ['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], + project.projectPath + ); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); const json = JSON.parse(result.stdout); @@ -262,7 +268,10 @@ describe('integration: add gateway with S3 URI schema target', () => { describe('S3 URI openApiSchema lifecycle', () => { it('adds a gateway and credential', async () => { - const result = await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + const result = await runCLI( + ['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], + project.projectPath + ); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); const credResult = await runCLI( @@ -331,7 +340,7 @@ describe('integration: add gateway with S3 URI and bucketOwnerAccountId', () => }); it('adds a gateway and target with --schema-s3-account', async () => { - await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + await runCLI(['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], project.projectPath); await runCLI( ['add', 'credential', '--name', 'CrossApiKey', '--api-key', 'test-key', '--json'], project.projectPath @@ -388,7 +397,10 @@ describe('integration: add gateway with Smithy model target', () => { describe('smithyModel lifecycle', () => { it('adds a gateway', async () => { - const result = await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + const result = await runCLI( + ['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], + project.projectPath + ); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); }); @@ -456,7 +468,7 @@ describe('integration: schema-based target validation errors', () => { beforeAll(async () => { project = await createTestProject({ noAgent: true }); - await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + await runCLI(['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], project.projectPath); }); afterAll(async () => { diff --git a/integ-tests/add-remove-online-insights.test.ts b/integ-tests/add-remove-online-insights.test.ts new file mode 100644 index 000000000..3d833c64d --- /dev/null +++ b/integ-tests/add-remove-online-insights.test.ts @@ -0,0 +1,329 @@ +import { + type TestProject, + createTestProject, + parseJsonOutput, + readProjectConfig, + runCLI, +} from '../src/test-utils/index.js'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const telemetry = createTelemetryHelper(); + +async function runSuccess(args: string[], cwd: string) { + const result = await runCLI(args, cwd, { env: telemetry.env }); + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', true); + return json as Record; +} + +async function runFailure(args: string[], cwd: string) { + const result = await runCLI(args, cwd, { env: telemetry.env }); + expect(result.exitCode).toBe(1); + const json: unknown = parseJsonOutput(result.stdout); + expect(json).toHaveProperty('success', false); + expect(json).toHaveProperty('error'); + return json as Record; +} + +describe('integration: add and remove online-insights configs', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + telemetry.destroy(); + }); + + describe('online-insights lifecycle', () => { + const configName = `IntegInsights${Date.now().toString().slice(-6)}`; + const insightId = 'Builtin.Insight.FailureAnalysis'; + + it('adds an online-insights config', async () => { + const json = await runSuccess( + [ + 'add', + 'online-insights', + '--name', + configName, + '--runtime', + project.agentName, + '--insights', + insightId, + '--sampling-rate', + '50', + '--json', + ], + project.projectPath + ); + expect(json.configName).toBe(configName); + + const config = await readProjectConfig(project.projectPath); + const found = config.onlineEvalConfigs.find((c: { name: string }) => c.name === configName); + expect(found).toBeDefined(); + expect(found!.agent).toBe(project.agentName); + expect(found!.insights).toContain(insightId); + expect(found!.samplingRate).toBe(50); + expect(found!.evaluators).toBeUndefined(); + }); + + it('rejects duplicate online-insights config name', async () => { + const json = await runFailure( + [ + 'add', + 'online-insights', + '--name', + configName, + '--runtime', + project.agentName, + '--insights', + insightId, + '--sampling-rate', + '50', + '--json', + ], + project.projectPath + ); + expect(json.error).toContain('already exists'); + }); + + it('adds online-insights with clustering frequencies', async () => { + const clusterName = `ClusterInsights${Date.now().toString().slice(-6)}`; + const json = await runSuccess( + [ + 'add', + 'online-insights', + '--name', + clusterName, + '--runtime', + project.agentName, + '--insights', + insightId, + '--sampling-rate', + '100', + '--clustering-frequency', + 'DAILY', + 'WEEKLY', + '--json', + ], + project.projectPath + ); + expect(json.configName).toBe(clusterName); + + const config = await readProjectConfig(project.projectPath); + const found = config.onlineEvalConfigs.find((c: { name: string }) => c.name === clusterName); + expect(found).toBeDefined(); + expect(found!.clusteringConfig).toBeDefined(); + expect(found!.clusteringConfig!.frequencies).toContain('DAILY'); + expect(found!.clusteringConfig!.frequencies).toContain('WEEKLY'); + }); + + it('adds online-insights with --enable-on-create', async () => { + const enabledName = `EnabledInsights${Date.now().toString().slice(-6)}`; + const json = await runSuccess( + [ + 'add', + 'online-insights', + '--name', + enabledName, + '--runtime', + project.agentName, + '--insights', + insightId, + '--sampling-rate', + '75', + '--enable-on-create', + '--json', + ], + project.projectPath + ); + expect(json.configName).toBe(enabledName); + + const config = await readProjectConfig(project.projectPath); + const found = config.onlineEvalConfigs.find((c: { name: string }) => c.name === enabledName); + expect(found).toBeDefined(); + expect(found!.enableOnCreate).toBe(true); + }); + + it('adds online-insights with endpoint', async () => { + // First add an endpoint to the runtime + await runSuccess( + ['add', 'runtime-endpoint', '--runtime', project.agentName, '--endpoint', 'prod', '--version', '1', '--json'], + project.projectPath + ); + + const epName = `EPInsights${Date.now().toString().slice(-6)}`; + const json = await runSuccess( + [ + 'add', + 'online-insights', + '--name', + epName, + '--runtime', + project.agentName, + '--insights', + insightId, + '--sampling-rate', + '50', + '--endpoint', + 'prod', + '--json', + ], + project.projectPath + ); + expect(json.configName).toBe(epName); + + const config = await readProjectConfig(project.projectPath); + const found = config.onlineEvalConfigs.find((c: { name: string }) => c.name === epName); + expect(found).toBeDefined(); + expect(found!.endpoint).toBe('prod'); + }); + + it('rejects online-insights with non-existent endpoint', async () => { + const json = await runFailure( + [ + 'add', + 'online-insights', + '--name', + 'BadEP', + '--runtime', + project.agentName, + '--insights', + insightId, + '--sampling-rate', + '50', + '--endpoint', + 'nonexistent', + '--json', + ], + project.projectPath + ); + expect(json.error).toContain('nonexistent'); + }); + + it('removes the online-insights config', async () => { + await runSuccess(['remove', 'online-insights', '--name', configName, '--json'], project.projectPath); + + const config = await readProjectConfig(project.projectPath); + const found = config.onlineEvalConfigs.find( + (c: { name: string; insights?: string[] }) => c.name === configName && c.insights?.length + ); + expect(found).toBeUndefined(); + telemetry.assertMetricEmitted({ command: 'remove.online-insights', exit_reason: 'success' }); + }); + }); + + describe('error cases', () => { + it('rejects online-insights with missing --runtime', async () => { + const json = await runFailure( + [ + 'add', + 'online-insights', + '--name', + 'SomeConfig', + '--insights', + 'Builtin.Insight.FailureAnalysis', + '--sampling-rate', + '50', + '--json', + ], + project.projectPath + ); + expect(json.error).toContain('--runtime'); + }); + + it('rejects online-insights with missing --insights', async () => { + const json = await runFailure( + [ + 'add', + 'online-insights', + '--name', + 'SomeConfig', + '--runtime', + project.agentName, + '--sampling-rate', + '50', + '--json', + ], + project.projectPath + ); + expect(json.error).toContain('--insights'); + }); + + it('rejects online-insights with missing --sampling-rate', async () => { + const json = await runFailure( + [ + 'add', + 'online-insights', + '--name', + 'SomeConfig', + '--runtime', + project.agentName, + '--insights', + 'Builtin.Insight.FailureAnalysis', + '--json', + ], + project.projectPath + ); + expect(json.error).toContain('--sampling-rate'); + }); + + it('rejects online-insights with invalid sampling rate (too high)', async () => { + const json = await runFailure( + [ + 'add', + 'online-insights', + '--name', + 'SomeConfig', + '--runtime', + project.agentName, + '--insights', + 'Builtin.Insight.FailureAnalysis', + '--sampling-rate', + '200', + '--json', + ], + project.projectPath + ); + expect(json.error).toContain('sampling-rate'); + }); + + it('rejects online-insights with invalid sampling rate (too low)', async () => { + const json = await runFailure( + [ + 'add', + 'online-insights', + '--name', + 'SomeConfig', + '--runtime', + project.agentName, + '--insights', + 'Builtin.Insight.FailureAnalysis', + '--sampling-rate', + '0', + '--json', + ], + project.projectPath + ); + expect(json.error).toContain('sampling-rate'); + }); + + it('fails to remove non-existent online-insights config', async () => { + const json = await runFailure( + ['remove', 'online-insights', '--name', 'NonExistent', '--json'], + project.projectPath + ); + expect(json.error).toContain('not found'); + telemetry.assertMetricEmitted({ command: 'remove.online-insights', exit_reason: 'failure' }); + }); + }); +}); diff --git a/integ-tests/promote-ab-test.test.ts b/integ-tests/promote-ab-test.test.ts new file mode 100644 index 000000000..827038204 --- /dev/null +++ b/integ-tests/promote-ab-test.test.ts @@ -0,0 +1,161 @@ +import { promoteABTestConfig } from '../src/cli/operations/jobs/ab-test/promote.js'; +import type { ABTestJobRecord } from '../src/cli/operations/jobs/shared/types.js'; +import { ConfigIO } from '../src/lib'; +import { type TestProject, createTestProject } from '../src/test-utils/index.js'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +/** + * Integration coverage for A/B-test promotion against a REAL on-disk agentcore.json. + * + * Unit tests mock ConfigIO; here promote does the genuine read → mutate → write → re-validate + * round-trip through ConfigIO + the Zod schema, so a malformed write (e.g. an httpRuntime shape the + * schema rejects) would surface as a real failure. Covers the "promote everything" paths: + * - version-bump (same runtime, named endpoints) + * - repoint to a different runtime + * - repoint when the default (unnamed) endpoint is used + * + * promoteABTestConfig() constructs `new ConfigIO()` internally, which discovers the project via + * INIT_CWD/cwd — so each test points INIT_CWD at the temp project before calling it. + */ +describe('integration: ab-test promote (real config round-trip)', () => { + let project: TestProject; + let configIO: ConfigIO; + const originalInitCwd = process.env.INIT_CWD; + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + // promote()'s internal `new ConfigIO()` resolves the project from INIT_CWD (walks up to the + // agentcore/ dir). The explicit ConfigIO below needs the agentcore/ dir as its baseDir. + process.env.INIT_CWD = project.projectPath; + configIO = new ConfigIO({ baseDir: join(project.projectPath, 'agentcore') }); + }); + + afterAll(async () => { + if (originalInitCwd === undefined) delete process.env.INIT_CWD; + else process.env.INIT_CWD = originalInitCwd; + await project.cleanup(); + }); + + // Build N schema-valid runtimes by cloning the project's real runtime (so build/entrypoint/etc. + // satisfy the Zod schema) and overriding name + endpoints. + async function makeRuntimes( + specs: { name: string; endpoints: Record }[] + ): Promise { + const spec = await configIO.readProjectSpec(); + const base = spec.runtimes[0]; + return specs.map(s => ({ ...base, name: s.name, endpoints: s.endpoints })); + } + + // Each test rewrites the runtimes + gateway from scratch so cases don't bleed into each other. + // httpRuntime targets require the gateway to have protocolType: "None". + async function seedProject(runtimes: unknown[], targets: unknown[]): Promise { + const spec = await configIO.readProjectSpec(); + const next = { + ...spec, + runtimes, + agentCoreGateways: [{ name: 'my-gw', protocolType: 'None', targets }], + }; + await configIO.writeProjectSpec(next as never); + } + + function record(): ABTestJobRecord { + return { + type: 'ab-test', + id: 'ab-integ', + arn: 'arn:aws:bedrock-agentcore:us-east-1:1:ab-test/ab-integ', + status: 'STOPPED', + lifecycleStatus: 'STOPPED', + createdAt: '2026-01-01T00:00:00Z', + agent: 'my-agent', + name: 'integTest', + mode: 'target-based', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:1:gateway/my-gw', + gatewayName: 'my-gw', + variants: [ + { name: 'C', weight: 50, targetName: 'ctrl-target' }, + { name: 'T1', weight: 50, targetName: 'treat-target' }, + ], + evaluationConfig: { onlineEvaluationConfigArn: 'arn:aws:eval:config' }, + }; + } + + it('version-bumps control to the treatment endpoint version (same runtime)', async () => { + await seedProject( + await makeRuntimes([{ name: 'my_runtime', endpoints: { control: { version: 1 }, treatment: { version: 7 } } }]), + [ + { + name: 'ctrl-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my_runtime', runtimeEndpoint: 'control' }, + }, + { + name: 'treat-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my_runtime', runtimeEndpoint: 'treatment' }, + }, + ] + ); + + const result = await promoteABTestConfig(record()); + expect(result.promoted).toBe(true); + + const after = await configIO.readProjectSpec(); + const rt = after.runtimes.find(r => r.name === 'my_runtime')!; + expect(rt.endpoints?.control?.version).toBe(7); + }); + + it('repoints control to a different treatment runtime', async () => { + await seedProject( + await makeRuntimes([ + { name: 'runtime_a', endpoints: { prod: { version: 1 } } }, + { name: 'runtime_b', endpoints: { prod: { version: 5 } } }, + ]), + [ + { + name: 'ctrl-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'runtime_a', runtimeEndpoint: 'prod' }, + }, + { + name: 'treat-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'runtime_b', runtimeEndpoint: 'prod' }, + }, + ] + ); + + const result = await promoteABTestConfig(record()); + expect(result.promoted).toBe(true); + + const after = await configIO.readProjectSpec(); + const ctrl = after.agentCoreGateways!.find(g => g.name === 'my-gw')!.targets!.find(t => t.name === 'ctrl-target')!; + expect(ctrl.httpRuntime!.runtime).toBe('runtime_b'); + expect(ctrl.httpRuntime!.runtimeEndpoint).toBe('prod'); + }); + + it('repoints control when variants use the default (unnamed) endpoint', async () => { + await seedProject( + await makeRuntimes([ + { name: 'runtime_a', endpoints: {} }, + { name: 'runtime_b', endpoints: {} }, + ]), + [ + { name: 'ctrl-target', targetType: 'httpRuntime', httpRuntime: { runtime: 'runtime_a' } }, + { name: 'treat-target', targetType: 'httpRuntime', httpRuntime: { runtime: 'runtime_b' } }, + ] + ); + + const result = await promoteABTestConfig(record()); + expect(result.promoted).toBe(true); + + const after = await configIO.readProjectSpec(); + const ctrl = after.agentCoreGateways!.find(g => g.name === 'my-gw')!.targets!.find(t => t.name === 'ctrl-target')!; + expect(ctrl.httpRuntime!.runtime).toBe('runtime_b'); + }); +}); diff --git a/integ-tests/recommendation.test.ts b/integ-tests/recommendation.test.ts index dc3037a3e..a79a153f5 100644 --- a/integ-tests/recommendation.test.ts +++ b/integ-tests/recommendation.test.ts @@ -70,53 +70,6 @@ describe('integration: run recommendation CLI validation', () => { }); describe('system-prompt recommendation input validation', () => { - it('fails when agent not deployed (inline input)', async () => { - const result = await runCLI( - [ - 'run', - 'recommendation', - '--runtime', - project.agentName, - '--evaluator', - 'Builtin.Faithfulness', - '--inline', - 'You are a helpful assistant.', - '--json', - ], - project.projectPath - ); - - expect(result.exitCode).toBe(1); - const json = parseJsonOutput(result.stdout) as Record; - expect(json.success).toBe(false); - expect(json.error).toContain('deployed'); - }); - - it('fails when agent not deployed (file input)', async () => { - const promptFile = join(project.projectPath, 'system-prompt.txt'); - await writeFile(promptFile, 'You are a helpful assistant for testing.'); - - const result = await runCLI( - [ - 'run', - 'recommendation', - '--runtime', - project.agentName, - '--evaluator', - 'Builtin.Faithfulness', - '--prompt-file', - promptFile, - '--json', - ], - project.projectPath - ); - - expect(result.exitCode).toBe(1); - const json = parseJsonOutput(result.stdout) as Record; - expect(json.success).toBe(false); - expect(json.error).toContain('deployed'); - }); - it('fails with non-existent prompt file', async () => { const result = await runCLI( [ @@ -137,61 +90,6 @@ describe('integration: run recommendation CLI validation', () => { }); }); - describe('tool-description recommendation input validation', () => { - it('fails when agent not deployed (tool-description type with --tools)', async () => { - const result = await runCLI( - [ - 'run', - 'recommendation', - '--type', - 'tool-description', - '--runtime', - project.agentName, - '--tools', - 'search:Searches the web for information', - '--tools', - 'calculator:Performs math calculations', - '--json', - ], - project.projectPath - ); - - expect(result.exitCode).toBe(1); - const json = parseJsonOutput(result.stdout) as Record; - expect(json.success).toBe(false); - expect(json.error).toContain('deployed'); - }); - }); - - describe('config bundle source validation', () => { - it('fails when bundle not found in deployed state', async () => { - const result = await runCLI( - [ - 'run', - 'recommendation', - '--runtime', - project.agentName, - '--evaluator', - 'Builtin.Faithfulness', - '--bundle-name', - 'NonExistentBundle', - '--bundle-version', - 'v1', - '--system-prompt-json-path', - 'systemPrompt', - '--json', - ], - project.projectPath - ); - - expect(result.exitCode).toBe(1); - const json = parseJsonOutput(result.stdout) as Record; - expect(json.success).toBe(false); - // Fails at agent resolution (not deployed) before bundle resolution - expect(json.error).toContain('deployed'); - }); - }); - describe('spans file validation', () => { it('fails when spans file does not exist', async () => { const result = await runCLI( @@ -238,53 +136,4 @@ describe('integration: run recommendation CLI validation', () => { expect(result.exitCode).toBe(1); }); }); - - describe('lookback and session options', () => { - it('accepts --lookback flag (fails at deploy check, not parsing)', async () => { - const result = await runCLI( - [ - 'run', - 'recommendation', - '--runtime', - project.agentName, - '--evaluator', - 'Builtin.Faithfulness', - '--inline', - 'You are a helpful assistant.', - '--lookback', - '14', - '--json', - ], - project.projectPath - ); - - expect(result.exitCode).toBe(1); - const json = parseJsonOutput(result.stdout) as Record; - expect(json.error).toContain('deployed'); - }); - - it('accepts --session-id flag (fails at deploy check, not parsing)', async () => { - const result = await runCLI( - [ - 'run', - 'recommendation', - '--runtime', - project.agentName, - '--evaluator', - 'Builtin.Faithfulness', - '--inline', - 'You are a helpful assistant.', - '--session-id', - 'sess-001', - 'sess-002', - '--json', - ], - project.projectPath - ); - - expect(result.exitCode).toBe(1); - const json = parseJsonOutput(result.stdout) as Record; - expect(json.error).toContain('deployed'); - }); - }); }); diff --git a/integ-tests/run-ab-test.test.ts b/integ-tests/run-ab-test.test.ts new file mode 100644 index 000000000..cce83e490 --- /dev/null +++ b/integ-tests/run-ab-test.test.ts @@ -0,0 +1,116 @@ +import { type TestProject, createTestProject, parseJsonOutput, runCLI } from '../src/test-utils/index.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +/** + * Client-side CLI validation for `agentcore run ab-test` (the fire-and-forget jobs-model command + * that replaced the old `add/remove ab-test` primitive). No live AWS — every case here must fail + * fast on local validation before any API call. Mirrors integ-tests/recommendation.test.ts. + */ +describe('integration: run ab-test CLI validation', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + describe('required flags', () => { + it('requires --name and --gateway in non-interactive (JSON) mode', async () => { + const result = await runCLI(['run', 'ab-test', '--json'], project.projectPath); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('--name'); + expect(json.error).toContain('--gateway'); + }); + + it('rejects an invalid --mode', async () => { + const result = await runCLI( + ['run', 'ab-test', '--name', 'MyTest', '--gateway', 'MyGw', '--mode', 'bogus-mode', '--json'], + project.projectPath + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('--mode'); + }); + }); + + describe('variant weight validation', () => { + it('rejects weights that do not sum to 100', async () => { + const result = await runCLI( + [ + 'run', + 'ab-test', + '--name', + 'MyTest', + '--gateway', + 'MyGw', + '--control-weight', + '60', + '--treatment-weight', + '60', + '--json', + ], + project.projectPath + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('sum to 100'); + }); + + it('rejects a non-integer / out-of-range weight', async () => { + const result = await runCLI( + [ + 'run', + 'ab-test', + '--name', + 'MyTest', + '--gateway', + 'MyGw', + '--control-weight', + '150', + '--treatment-weight', + '50', + '--json', + ], + project.projectPath + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('between 0 and 100'); + }); + }); + + describe('mode-specific required inputs', () => { + it('config-bundle mode requires control/treatment bundle names and versions', async () => { + const result = await runCLI( + ['run', 'ab-test', '--name', 'MyTest', '--gateway', 'MyGw', '--mode', 'config-bundle', '--json'], + project.projectPath + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + }); + + it('target-based mode requires control/treatment targets', async () => { + const result = await runCLI( + ['run', 'ab-test', '--name', 'MyTest', '--gateway', 'MyGw', '--mode', 'target-based', '--json'], + project.projectPath + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + }); + }); +}); diff --git a/integ-tests/run-insights.test.ts b/integ-tests/run-insights.test.ts new file mode 100644 index 000000000..3b9b46b83 --- /dev/null +++ b/integ-tests/run-insights.test.ts @@ -0,0 +1,207 @@ +import { type TestProject, createTestProject, parseJsonOutput, runCLI } from '../src/test-utils/index.js'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('integration: run insights command validation', () => { + let project: TestProject; + const telemetry = createTelemetryHelper(); + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + telemetry.destroy(); + }); + + it('fails when agent is not deployed (no deployed state)', async () => { + const result = await runCLI(['run', 'insights', '--runtime', project.agentName, '--json'], project.projectPath, { + env: telemetry.env, + }); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toBeTruthy(); + }); + + it('fails with --name that violates naming constraints', async () => { + const result = await runCLI( + ['run', 'insights', '--runtime', project.agentName, '--name', '123-invalid-start', '--json'], + project.projectPath, + { env: telemetry.env } + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toBeTruthy(); + }); + + it('accepts --insights flag with custom insight IDs', async () => { + const result = await runCLI( + ['run', 'insights', '--runtime', project.agentName, '--insights', 'Builtin.Insight.FailureAnalysis', '--json'], + project.projectPath, + { env: telemetry.env } + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.error).not.toContain('--insights'); + }); + + it('accepts --evaluator flag for recommendation chaining', async () => { + const result = await runCLI( + ['run', 'insights', '--runtime', project.agentName, '--evaluator', 'Builtin.Accuracy', '--json'], + project.projectPath, + { env: telemetry.env } + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.error).not.toContain('--evaluator'); + }); + + it('accepts --lookback-days flag', async () => { + const result = await runCLI( + ['run', 'insights', '--runtime', project.agentName, '--lookback-days', '14', '--json'], + project.projectPath, + { env: telemetry.env } + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.error).not.toContain('--lookback-days'); + }); + + it('accepts --session-ids flag', async () => { + const result = await runCLI( + ['run', 'insights', '--runtime', project.agentName, '--session-ids', 'sess-001', 'sess-002', '--json'], + project.projectPath, + { env: telemetry.env } + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.error).not.toContain('--session-ids'); + }); + + it('accepts --online-eval-config-arn as data source (no --runtime needed)', async () => { + const result = await runCLI( + [ + 'run', + 'insights', + '--online-eval-config-arn', + 'arn:aws:bedrock:us-east-1:123456789012:online-evaluation-config/test', + '--json', + ], + project.projectPath, + { env: telemetry.env } + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.error).not.toContain('--runtime'); + expect(json.error).not.toContain('required'); + }); +}); + +describe('integration: view insights command', () => { + let project: TestProject; + const telemetry = createTelemetryHelper(); + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + telemetry.destroy(); + }); + + it('returns empty list when no insights jobs exist', async () => { + const result = await runCLI(['view', 'insights', '--json'], project.projectPath, { env: telemetry.env }); + expect(result.exitCode).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(true); + expect(json.insights).toEqual([]); + }); + + it('returns not-found for a non-existent insights job ID', async () => { + const result = await runCLI(['view', 'insights', 'nonexistent-id', '--json'], project.projectPath, { + env: telemetry.env, + }); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('not found'); + }); +}); + +describe('integration: pause/resume online-insights validation', () => { + let project: TestProject; + const telemetry = createTelemetryHelper(); + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + telemetry.destroy(); + }); + + it('pause online-insights fails without name or --arn', async () => { + const result = await runCLI(['pause', 'online-insights', '--json'], project.projectPath, { env: telemetry.env }); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('name or --arn'); + }); + + it('resume online-insights fails without name or --arn', async () => { + const result = await runCLI(['resume', 'online-insights', '--json'], project.projectPath, { env: telemetry.env }); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('name or --arn'); + }); +}); + +describe('integration: archive insights validation', () => { + let project: TestProject; + const telemetry = createTelemetryHelper(); + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + telemetry.destroy(); + }); + + it('archive insights fails for non-existent ID', async () => { + const result = await runCLI(['archive', 'insights', '--id', 'nonexistent-job-id', '--json'], project.projectPath, { + env: telemetry.env, + }); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toContain('not found'); + }); +}); diff --git a/integ-tests/run-recommendation-from-insights.test.ts b/integ-tests/run-recommendation-from-insights.test.ts new file mode 100644 index 000000000..1550d59b4 --- /dev/null +++ b/integ-tests/run-recommendation-from-insights.test.ts @@ -0,0 +1,72 @@ +import { type TestProject, createTestProject, parseJsonOutput, runCLI } from '../src/test-utils/index.js'; +import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const telemetry = createTelemetryHelper(); + +describe('integration: run recommendation --from-insights', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + telemetry.destroy(); + }); + + it('accepts --from-insights flag (fails on missing insights job, not flag parsing)', async () => { + const result = await runCLI( + ['run', 'recommendation', '--runtime', project.agentName, '--from-insights', 'some-insights-job-id', '--json'], + project.projectPath, + { env: telemetry.env } + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + // Should fail because the insights job doesn't exist, not because --from-insights is unrecognized + expect(json.error).not.toContain('Unknown option'); + expect(json.error).not.toContain('--from-insights'); + }); + + it('accepts --batch-evaluation-arn flag (fails on API, not flag parsing)', async () => { + const result = await runCLI( + [ + 'run', + 'recommendation', + '--runtime', + project.agentName, + '--batch-evaluation-arn', + 'arn:aws:bedrock:us-east-1:123456789012:batch-evaluation/test', + '--json', + ], + project.projectPath, + { env: telemetry.env } + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).not.toContain('Unknown option'); + expect(json.error).not.toContain('--batch-evaluation-arn'); + }); + + it('--from-insights makes --runtime and --evaluator optional', async () => { + const result = await runCLI( + ['run', 'recommendation', '--from-insights', 'some-insights-job-id', '--json'], + project.projectPath, + { env: telemetry.env } + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + // Should NOT complain about missing --runtime or --evaluator + expect(json.error).not.toContain('--runtime'); + expect(json.error).not.toContain('--evaluator'); + }); +}); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 1f3fe3ea5..07c43ad44 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.18.0", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.18.0", + "version": "0.19.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -15,15 +15,15 @@ "@aws-sdk/client-application-signals": "^3.1003.0", "@aws-sdk/client-bedrock": "^3.1012.0", "@aws-sdk/client-bedrock-agent": "^3.1012.0", - "@aws-sdk/client-bedrock-agentcore": "^3.1020.0", - "@aws-sdk/client-bedrock-agentcore-control": "^3.1054.0", + "@aws-sdk/client-bedrock-agentcore": "^3.1061.0", + "@aws-sdk/client-bedrock-agentcore-control": "^3.1061.0", "@aws-sdk/client-bedrock-runtime": "^3.893.0", "@aws-sdk/client-cloudformation": "^3.893.0", "@aws-sdk/client-cloudwatch-logs": "^3.893.0", - "@aws-sdk/client-efs": "^3.1049.0", + "@aws-sdk/client-efs": "^3.1067.0", "@aws-sdk/client-resource-groups-tagging-api": "^3.893.0", "@aws-sdk/client-s3": "^3.1012.0", - "@aws-sdk/client-s3files": "^3.1049.0", + "@aws-sdk/client-s3files": "^3.1067.0", "@aws-sdk/client-sts": "^3.893.0", "@aws-sdk/client-xray": "^3.1003.0", "@aws-sdk/credential-providers": "^3.893.0", @@ -834,53 +834,20 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore": { - "version": "3.1037.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1037.0.tgz", - "integrity": "sha512-8WmZulMmFnCWFuX2rDBoZdebCMmmrAi1VABsLgm4O73w3+s7tcON1YgspG9gTevuVRtOVdk1B6TLw2Mo8NBHSQ==", + "version": "3.1066.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1066.0.tgz", + "integrity": "sha512-7wpBOVhp5zWi2Ngd726MJY5wN5QSX9hBDDGdc2kgZaOoW15iiA87atCbPGwjZTspTIBJH73737jgRIhT/O484A==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.5", - "@aws-sdk/credential-provider-node": "^3.972.36", - "@aws-sdk/middleware-host-header": "^3.972.10", - "@aws-sdk/middleware-logger": "^3.972.10", - "@aws-sdk/middleware-recursion-detection": "^3.972.11", - "@aws-sdk/middleware-user-agent": "^3.972.35", - "@aws-sdk/region-config-resolver": "^3.972.13", - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/util-endpoints": "^3.996.8", - "@aws-sdk/util-user-agent-browser": "^3.972.10", - "@aws-sdk/util-user-agent-node": "^3.973.21", - "@smithy/config-resolver": "^4.4.17", - "@smithy/core": "^3.23.17", - "@smithy/eventstream-serde-browser": "^4.2.14", - "@smithy/eventstream-serde-config-resolver": "^4.3.14", - "@smithy/eventstream-serde-node": "^4.2.14", - "@smithy/fetch-http-handler": "^5.3.17", - "@smithy/hash-node": "^4.2.14", - "@smithy/invalid-dependency": "^4.2.14", - "@smithy/middleware-content-length": "^4.2.14", - "@smithy/middleware-endpoint": "^4.4.32", - "@smithy/middleware-retry": "^4.5.5", - "@smithy/middleware-serde": "^4.2.20", - "@smithy/middleware-stack": "^4.2.14", - "@smithy/node-config-provider": "^4.3.14", - "@smithy/node-http-handler": "^4.6.1", - "@smithy/protocol-http": "^5.3.14", - "@smithy/smithy-client": "^4.12.13", - "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.49", - "@smithy/util-defaults-mode-node": "^4.2.54", - "@smithy/util-endpoints": "^3.4.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-retry": "^4.3.4", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.55", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -888,20 +855,20 @@ } }, "node_modules/@aws-sdk/client-bedrock-agentcore-control": { - "version": "3.1057.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1057.0.tgz", - "integrity": "sha512-REASfgMI9i8k55OJSdSWn7rcoJIKllWMfffoR/tbu4+JLcbrV9j7uPKQg085d0w3Vx3NRrjoNlBjijq7W2dIeQ==", + "version": "3.1066.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1066.0.tgz", + "integrity": "sha512-CKgcGkhskee4eqiCU8ZMsC1Ru4d0GoSzywEY9bhvCXgeNlFHrLpnO8jhaz3bIh4nDa0/4ABsTX5vyM/0Giz0wA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/credential-provider-node": "^3.972.47", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/fetch-http-handler": "^5.4.5", - "@smithy/node-http-handler": "^4.7.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.55", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -1463,20 +1430,20 @@ } }, "node_modules/@aws-sdk/client-efs": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-efs/-/client-efs-3.1049.0.tgz", - "integrity": "sha512-gFjP27S8OYbpm/HUrCcYriqTprD3bYBdbOP1eEtZkrKnDKE9GsX+hZiFRd/mFjzoEHcduK9Emtw7U3oNYrX4DA==", + "version": "3.1067.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-efs/-/client-efs-3.1067.0.tgz", + "integrity": "sha512-Bo7hR++vEnqvBHAGQSUkq/7OJhG2mA80Tf0gmi/yntfr/Zj+6D7/xsp0S5Qtk7eEcGXRauj+sbsvcq3ZbTyyfQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-node": "^3.972.43", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.55", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -1859,20 +1826,20 @@ } }, "node_modules/@aws-sdk/client-s3files": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3files/-/client-s3files-3.1049.0.tgz", - "integrity": "sha512-nqOZ5SGNmaaUV/AmTFulGVWzDclYt/1Yk/rPvbqdre40aBi+2rlY0EauVcSDszXzUc5AjPMNrOINQ9z3SXq1dA==", + "version": "3.1067.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3files/-/client-s3files-3.1067.0.tgz", + "integrity": "sha512-Kj++SPSxeryjDM/60Q++NoOnNePSYmpAyUNSl99llpvP+7V2htIIUSm/gSQxLgF199mL6DPjPqUxdFrpGDHlBg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-node": "^3.972.43", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.55", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2132,17 +2099,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.15.tgz", - "integrity": "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==", + "version": "3.974.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.20.tgz", + "integrity": "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@aws-sdk/xml-builder": "^3.972.26", + "@aws-sdk/types": "^3.973.12", + "@aws-sdk/xml-builder": "^3.972.29", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.5", - "@smithy/signature-v4": "^5.4.5", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -2214,15 +2181,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.41.tgz", - "integrity": "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==", + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.46.tgz", + "integrity": "sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2230,17 +2197,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.43.tgz", - "integrity": "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==", + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.48.tgz", + "integrity": "sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/fetch-http-handler": "^5.4.5", - "@smithy/node-http-handler": "^4.7.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2248,23 +2215,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.46", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.46.tgz", - "integrity": "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA==", + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.53.tgz", + "integrity": "sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/credential-provider-env": "^3.972.41", - "@aws-sdk/credential-provider-http": "^3.972.43", - "@aws-sdk/credential-provider-login": "^3.972.45", - "@aws-sdk/credential-provider-process": "^3.972.41", - "@aws-sdk/credential-provider-sso": "^3.972.45", - "@aws-sdk/credential-provider-web-identity": "^3.972.45", - "@aws-sdk/nested-clients": "^3.997.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/credential-provider-imds": "^4.3.6", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-env": "^3.972.46", + "@aws-sdk/credential-provider-http": "^3.972.48", + "@aws-sdk/credential-provider-login": "^3.972.52", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@aws-sdk/credential-provider-sso": "^3.972.52", + "@aws-sdk/credential-provider-web-identity": "^3.972.52", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2272,16 +2239,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.45", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.45.tgz", - "integrity": "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.52.tgz", + "integrity": "sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/nested-clients": "^3.997.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2289,21 +2256,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.47", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.47.tgz", - "integrity": "sha512-HrId+C0DWA5qDIyLG64/kjUB2RNtPypxmABnIctK+TA1P1kHlOYoE/Wf5T5tKOMKgb08P7k/zNyhvfJ3lh5Oag==", + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.55.tgz", + "integrity": "sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.41", - "@aws-sdk/credential-provider-http": "^3.972.43", - "@aws-sdk/credential-provider-ini": "^3.972.46", - "@aws-sdk/credential-provider-process": "^3.972.41", - "@aws-sdk/credential-provider-sso": "^3.972.45", - "@aws-sdk/credential-provider-web-identity": "^3.972.45", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/credential-provider-imds": "^4.3.6", - "@smithy/types": "^4.14.2", + "@aws-sdk/credential-provider-env": "^3.972.46", + "@aws-sdk/credential-provider-http": "^3.972.48", + "@aws-sdk/credential-provider-ini": "^3.972.53", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@aws-sdk/credential-provider-sso": "^3.972.52", + "@aws-sdk/credential-provider-web-identity": "^3.972.52", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2311,15 +2278,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.41.tgz", - "integrity": "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==", + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.46.tgz", + "integrity": "sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2327,17 +2294,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.45", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.45.tgz", - "integrity": "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.52.tgz", + "integrity": "sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/nested-clients": "^3.997.13", - "@aws-sdk/token-providers": "3.1056.0", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/token-providers": "3.1066.0", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2345,16 +2312,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.45", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.45.tgz", - "integrity": "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.52.tgz", + "integrity": "sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/nested-clients": "^3.997.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2694,20 +2661,20 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.13.tgz", - "integrity": "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==", + "version": "3.997.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.20.tgz", + "integrity": "sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/signature-v4-multi-region": "^3.996.30", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/fetch-http-handler": "^5.4.5", - "@smithy/node-http-handler": "^4.7.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/signature-v4-multi-region": "^3.996.34", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2731,14 +2698,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz", - "integrity": "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==", + "version": "3.996.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.34.tgz", + "integrity": "sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.9", - "@smithy/signature-v4": "^5.4.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/types": "^3.973.12", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2746,16 +2713,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1056.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1056.0.tgz", - "integrity": "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==", + "version": "3.1066.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1066.0.tgz", + "integrity": "sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.15", - "@aws-sdk/nested-clients": "^3.997.13", - "@aws-sdk/types": "^3.973.9", - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -2763,12 +2730,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", - "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.12.tgz", + "integrity": "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.2", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -4182,20 +4149,6 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/protobufjs": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.2.0.tgz", - "integrity": "sha512-oI+GC9iPxrQEr6wragljFKH46/r3rNsm6eg7F2fp6kBUMnf6/mesDRdBuF4gK+OyaKJ8N4C1B9s9cCeYdqFikg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/@opentelemetry/otlp-exporter-base": { "version": "0.215.0", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.215.0.tgz", @@ -4327,20 +4280,6 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/protobufjs": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.2.0.tgz", - "integrity": "sha512-oI+GC9iPxrQEr6wragljFKH46/r3rNsm6eg7F2fp6kBUMnf6/mesDRdBuF4gK+OyaKJ8N4C1B9s9cCeYdqFikg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/@opentelemetry/otlp-transformer": { "version": "0.213.0", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz", @@ -4585,70 +4524,6 @@ "node": ">=18" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" - }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -5126,13 +5001,13 @@ } }, "node_modules/@smithy/core": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz", - "integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==", + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz", + "integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -5140,13 +5015,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.6.tgz", - "integrity": "sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.8.tgz", + "integrity": "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -5224,13 +5099,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz", - "integrity": "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.6.tgz", + "integrity": "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -5418,13 +5293,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", - "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.7.tgz", + "integrity": "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -5510,13 +5385,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.5.tgz", - "integrity": "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.6.tgz", + "integrity": "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.5", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -5542,9 +5417,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz", + "integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -6017,6 +5892,7 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -13740,24 +13616,12 @@ } }, "node_modules/protobufjs": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", - "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", - "hasInstallScript": true, + "version": "8.6.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.3.tgz", + "integrity": "sha512-alQyzT0j401LGBtwsqu6uprjR6pfNH1UJf9N6GBFMjIcd+HzTe0/HrjAbFCqun+zvnfLarrxAtMM2xvZ+kFZ5A==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -16152,6 +16016,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { diff --git a/package.json b/package.json index 22db4c52d..5594dd505 100644 --- a/package.json +++ b/package.json @@ -80,15 +80,15 @@ "@aws-sdk/client-application-signals": "^3.1003.0", "@aws-sdk/client-bedrock": "^3.1012.0", "@aws-sdk/client-bedrock-agent": "^3.1012.0", - "@aws-sdk/client-bedrock-agentcore": "^3.1020.0", - "@aws-sdk/client-bedrock-agentcore-control": "^3.1054.0", + "@aws-sdk/client-bedrock-agentcore": "^3.1061.0", + "@aws-sdk/client-bedrock-agentcore-control": "^3.1061.0", "@aws-sdk/client-bedrock-runtime": "^3.893.0", "@aws-sdk/client-cloudformation": "^3.893.0", "@aws-sdk/client-cloudwatch-logs": "^3.893.0", - "@aws-sdk/client-efs": "^3.1049.0", + "@aws-sdk/client-efs": "^3.1067.0", "@aws-sdk/client-resource-groups-tagging-api": "^3.893.0", "@aws-sdk/client-s3": "^3.1012.0", - "@aws-sdk/client-s3files": "^3.1049.0", + "@aws-sdk/client-s3files": "^3.1067.0", "@aws-sdk/client-sts": "^3.893.0", "@aws-sdk/client-xray": "^3.1003.0", "@aws-sdk/credential-providers": "^3.893.0", @@ -162,13 +162,15 @@ "minimatch": "GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74: minimatch 10.0.0-10.2.2 has ReDoS vulnerabilities. Multiple transitive deps (eslint, typescript-eslint, eslint-plugin-import, eslint-plugin-react, prettier-plugin-sort-imports, aws-cdk-lib) pin older versions. Remove this override once upstream packages update their minimatch dependency to >=10.2.3.", "glob": "glob <12 is deprecated and emits npm install warnings (https://github.com/isaacs/node-glob). Pulled in transitively via archiver-utils@5.0.2 (latest), which still pins glob@^10.0.0. archiver-utils only uses glob.sync(pattern, options), which remains compatible in glob@13. Remove this override once archiver-utils updates its glob dependency.", "fast-xml-parser": "GHSA-8gc5-j5rx-235r, GHSA-jp2q-39xq-3w4g: fast-xml-parser <=5.5.6 has entity expansion bypass (CVE-2026-33036, CVE-2026-33349). Transitive via @aws-sdk/xml-builder. Remove once @aws-sdk updates to fast-xml-parser >=5.5.7.", - "@aws-sdk/xml-builder": "aws/aws-sdk-js-v3#7867: @aws-sdk/xml-builder <3.972.14 does not configure maxTotalExpansions on fast-xml-parser, causing 'Entity expansion limit exceeded' on large CloudFormation responses. Remove once @aws-sdk/client-* deps are bumped past 3.972.14." + "@aws-sdk/xml-builder": "aws/aws-sdk-js-v3#7867: @aws-sdk/xml-builder <3.972.14 does not configure maxTotalExpansions on fast-xml-parser, causing 'Entity expansion limit exceeded' on large CloudFormation responses. Remove once @aws-sdk/client-* deps are bumped past 3.972.14.", + "protobufjs": "GHSA-f38q-mgvj-vph7, GHSA-wcpc-wj8m-hjx6, GHSA-94rc-8x27-4472: protobufjs <=7.6.2 / 8.0.0-8.5.0 has property shadowing and DoS vulnerabilities. Transitive via @opentelemetry/otlp-transformer. Remove once @opentelemetry updates to protobufjs >=7.6.3 or >=8.5.1." }, "overrides": { "minimatch": "10.2.4", "glob": "^13.0.0", "fast-xml-parser": "5.5.7", - "@aws-sdk/xml-builder": "3.972.15" + "@aws-sdk/xml-builder": "3.972.15", + "protobufjs": ">=7.6.3" }, "engines": { "node": ">=20" diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index 36e417528..8780a3ded 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -284,76 +284,22 @@ "filesystemConfigurations": { "type": "array", "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "sessionStorage": { - "type": "object", - "properties": { - "mountPath": { - "type": "string", - "minLength": 6, - "maxLength": 200, - "pattern": "^\\/mnt\\/[a-zA-Z0-9._-]+\\/?$" - } - }, - "required": ["mountPath"], - "additionalProperties": false - } - }, - "required": ["sessionStorage"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "efsAccessPoint": { - "type": "object", - "properties": { - "accessPointArn": { - "type": "string", - "pattern": "^arn:aws[-a-z]*:elasticfilesystem:[a-z][a-z0-9-]*:[0-9]{12}:access-point\\/fsap-[0-9a-f]{8,40}$" - }, - "mountPath": { - "type": "string", - "minLength": 6, - "maxLength": 200, - "pattern": "^\\/mnt\\/[a-zA-Z0-9._-]+\\/?$" - } - }, - "required": ["accessPointArn", "mountPath"], - "additionalProperties": false - } - }, - "required": ["efsAccessPoint"], - "additionalProperties": false - }, - { + "type": "object", + "properties": { + "sessionStorage": { "type": "object", "properties": { - "s3FilesAccessPoint": { - "type": "object", - "properties": { - "accessPointArn": { - "type": "string", - "pattern": "^arn:aws[-a-z]*:s3files:[a-z][a-z0-9-]*:[0-9]{12}:file-system\\/fs-[0-9a-f]{17,40}\\/access-point\\/fsap-[0-9a-f]{17,40}$" - }, - "mountPath": { - "type": "string", - "minLength": 6, - "maxLength": 200, - "pattern": "^\\/mnt\\/[a-zA-Z0-9._-]+\\/?$" - } - }, - "required": ["accessPointArn", "mountPath"], - "additionalProperties": false + "mountPath": { + "type": "string", + "pattern": "^\\/mnt\\/[^/]+$" } }, - "required": ["s3FilesAccessPoint"], + "required": ["mountPath"], "additionalProperties": false } - ] + }, + "required": ["sessionStorage"], + "additionalProperties": false } }, "endpoints": { @@ -604,27 +550,6 @@ }, "required": ["authorizerType", "name"], "additionalProperties": false - }, - { - "type": "object", - "properties": { - "authorizerType": { - "type": "string", - "const": "PaymentCredentialProvider" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[a-zA-Z0-9\\-_]+$" - }, - "provider": { - "type": "string", - "enum": ["CoinbaseCDP", "StripePrivy"] - } - }, - "required": ["authorizerType", "name", "provider"], - "additionalProperties": false } ] } @@ -2325,112 +2250,6 @@ "required": ["name", "schemaType", "config"], "additionalProperties": false } - }, - "payments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 48, - "pattern": "^[a-zA-Z][a-zA-Z0-9]{0,47}$" - }, - "authorizerType": { - "default": "AWS_IAM", - "type": "string", - "enum": ["AWS_IAM", "CUSTOM_JWT"] - }, - "authorizerConfiguration": { - "type": "object", - "properties": { - "customJWTAuthorizer": { - "type": "object", - "properties": { - "discoveryUrl": { - "type": "string", - "format": "uri" - }, - "allowedClients": { - "type": "array", - "items": { - "type": "string" - } - }, - "allowedAudience": { - "type": "array", - "items": { - "type": "string" - } - }, - "allowedScopes": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["discoveryUrl"], - "additionalProperties": false - } - }, - "required": ["customJWTAuthorizer"], - "additionalProperties": false - }, - "connectors": { - "default": [], - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 48, - "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,47}$" - }, - "provider": { - "default": "CoinbaseCDP", - "type": "string", - "enum": ["CoinbaseCDP", "StripePrivy"] - }, - "credentialName": { - "type": "string", - "minLength": 1 - } - }, - "required": ["name", "credentialName"], - "additionalProperties": false - } - }, - "description": { - "type": "string" - }, - "autoPayment": { - "default": true, - "type": "boolean" - }, - "defaultSpendLimit": { - "default": "10.00", - "type": "string" - }, - "paymentToolAllowlist": { - "type": "array", - "items": { - "type": "string" - } - }, - "networkPreferences": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["name"], - "additionalProperties": false - } } }, "required": ["name", "version"], diff --git a/scripts/run-e2e-local.sh b/scripts/run-e2e-local.sh index 6962c1bd2..26cd86385 100755 --- a/scripts/run-e2e-local.sh +++ b/scripts/run-e2e-local.sh @@ -8,6 +8,7 @@ # # Optional env vars: # AWS_REGION — defaults to us-east-1 +# CDK_REPO_PATH — local path to CDK constructs repo; if set, builds and uses it as CDK_TARBALL # BUILD_PREVIEW — set to 1 to build a preview CLI and run the harness e2e tests # (e2e-tests/harness-*.test.ts). Harness features are gated to preview # builds; without this they self-skip. The var is read at build time @@ -127,6 +128,16 @@ npm install -g "$TARBALL" echo "✅ Installed: $(agentcore --version)" echo "=== Running E2E tests ===" +if [[ -n "${CDK_REPO_PATH:-}" ]]; then + echo "=== Building CDK constructs from $CDK_REPO_PATH ===" + pushd "$CDK_REPO_PATH" > /dev/null + npm ci + npm run build + CDK_TARBALL_NAME=$(npm pack | tail -1) + export CDK_TARBALL="$CDK_REPO_PATH/$CDK_TARBALL_NAME" + popd > /dev/null + echo "✅ CDK_TARBALL=$CDK_TARBALL" +fi if [[ "$RUN_ALL" == "true" ]]; then echo "Running full e2e suite" npx vitest run --project e2e diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index f0dbd7112..4b42f93a5 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -44,8 +44,8 @@ agentcore status # checks deployment status exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/bin/cdk.ts should match snapshot 1`] = ` "#!/usr/bin/env node -import { AgentCoreStack } from '../lib/cdk-stack'; -import { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; +import { AgentCoreStack, type HarnessConfig } from '../lib/cdk-stack'; +import { ConfigIO, HarnessSpecSchema, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; import { App, type Environment } from 'aws-cdk-lib'; import * as path from 'path'; import * as fs from 'fs'; @@ -101,40 +101,61 @@ async function main() { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } - // Read harness configs for role creation. + // Read harness configs: the full validated spec drives the CFN resource; the + // role-scoped fields drive the IAM role + container build. const projectRoot = path.resolve(configRoot, '..'); - const harnessConfigs: { - name: string; - executionRoleArn?: string; - memoryName?: string; - containerUri?: string; - hasDockerfile?: boolean; - dockerfile?: string; - codeLocation?: string; - tools?: { type: string; name: string }[]; - apiKeyArn?: string; - efsAccessPoints?: { accessPointArn: string; mountPath: string }[]; - s3AccessPoints?: { accessPointArn: string; mountPath: string }[]; - apiFormat?: 'converse_stream' | 'responses' | 'chat_completions'; - }[] = []; - for (const entry of specAny.harnesses ?? []) { + + // Read non-S3 KB connector-config files and pass their parsed contents to the + // L3 verbatim. The L3 does not read files; it expects the parsed + // connectorParameters keyed by the data source's connectorConfigFile path. + const connectorParametersByFile: Record> = {}; + for (const kb of specAny.knowledgeBases ?? []) { + for (const ds of kb.dataSources ?? []) { + if (ds.type !== 'S3' && ds.connectorConfigFile) { + const abs = path.resolve(projectRoot, ds.connectorConfigFile); + try { + connectorParametersByFile[ds.connectorConfigFile] = JSON.parse(fs.readFileSync(abs, 'utf-8')); + } catch (err) { + throw new Error( + \`Could not read connector config '\${ds.connectorConfigFile}' for knowledge base '\${kb.name}' at \${abs}: \${err instanceof Error ? err.message : err}\` + ); + } + } + } + } + + // Harness is preview-gated. The CLI bundle bakes the preview flag at build time and + // forwards it to this child process via AGENTCORE_PREVIEW (see toolkit-lib/wrapper.ts). + // This app is built separately and cannot see that build-time define, so it gates on the + // env var. Absent/anything-but-'1' defaults to off so a stale harnesses[] entry in a + // non-preview build never synthesizes an AWS::BedrockAgentCore::Harness resource. + const previewEnabled = process.env.AGENTCORE_PREVIEW === '1'; + + const harnessConfigs: HarnessConfig[] = []; + for (const entry of previewEnabled ? (specAny.harnesses ?? []) : []) { const harnessDir = path.resolve(projectRoot, entry.path); const harnessPath = path.resolve(harnessDir, 'harness.json'); try { - const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8')); + const harnessSpec = HarnessSpecSchema.parse(JSON.parse(fs.readFileSync(harnessPath, 'utf-8'))); harnessConfigs.push({ name: entry.name, executionRoleArn: harnessSpec.executionRoleArn, - memoryName: harnessSpec.memory?.name, + // Only an \`existing\` memory ref carries a name to wire IAM against; managed memory is + // owned by the harness (no sibling) and disabled has none — both resolve to undefined. + memoryName: harnessSpec.memory?.mode === 'existing' ? harnessSpec.memory.name : undefined, containerUri: harnessSpec.containerUri, hasDockerfile: !!harnessSpec.dockerfile, dockerfile: harnessSpec.dockerfile, codeLocation: harnessSpec.dockerfile ? harnessDir : undefined, tools: harnessSpec.tools, + skills: harnessSpec.skills, apiKeyArn: harnessSpec.model?.apiKeyArn, efsAccessPoints: harnessSpec.efsAccessPoints, s3AccessPoints: harnessSpec.s3AccessPoints, apiFormat: harnessSpec.model?.apiFormat, + // Full spec + dir drive the AWS::BedrockAgentCore::Harness CFN resource. + spec: harnessSpec, + harnessDir, }); } catch (err) { throw new Error( @@ -201,6 +222,7 @@ async function main() { spec, mcpSpec, credentials, + connectorParametersByFile, harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, paymentSpec, env, @@ -349,24 +371,18 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/lib/cdk-stack.ts shou type AgentCoreProjectSpec, type AgentCoreMcpSpec, type CustomJWTAuthorizerConfig, + type HarnessDeploymentConfig, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; -export interface HarnessConfig { - name: string; - executionRoleArn?: string; - memoryName?: string; - containerUri?: string; - hasDockerfile?: boolean; - dockerfile?: string; - codeLocation?: string; - tools?: { type: string; name: string }[]; - apiKeyArn?: string; - efsAccessPoints?: { accessPointArn: string; mountPath: string }[]; - s3AccessPoints?: { accessPointArn: string; mountPath: string }[]; -} +/** + * Harness deployment config: role-scoped fields (for IAM role + container build) + * plus the full validated spec + its config directory so the L3 construct can + * synthesize the AWS::BedrockAgentCore::Harness resource. + */ +export type HarnessConfig = HarnessDeploymentConfig; export interface PaymentConnectorSpec { name: string; @@ -402,6 +418,11 @@ export interface AgentCoreStackProps extends StackProps { * Harness role configurations. */ harnesses?: HarnessConfig[]; + /** + * Parsed connectorParameters for non-S3 KB data sources, keyed by + * connectorConfigFile path. Forwarded to AgentCoreApplication. + */ + connectorParametersByFile?: Record>; /** * Payment specifications with resolved credential provider ARNs. */ @@ -441,7 +462,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials, harnesses, paymentSpec } = props; + const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile, paymentSpec } = props; // Create AgentCoreApplication with all agents and harness roles // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -449,6 +470,12 @@ export class AgentCoreStack extends Stack { if (harnesses?.length) { appProps.harnesses = harnesses; } + if (connectorParametersByFile && Object.keys(connectorParametersByFile).length > 0) { + appProps.connectorParametersByFile = connectorParametersByFile; + } + if (credentials) { + appProps.credentials = credentials; + } this.application = new AgentCoreApplication(this, 'Application', appProps as any); // Create AgentCoreMcp if there are gateways configured @@ -652,11 +679,11 @@ test('AgentCoreStack synthesizes with empty spec', () => { configBundles: [], policyEngines: [], payments: [], - configBundles: [], agentCoreGateways: [], mcpRuntimeTools: [], unassignedTargets: [], datasets: [], + knowledgeBases: [], }, }); const template = Template.fromStack(stack); @@ -811,6 +838,8 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "python/http/strands/base/model/__init__.py", "python/http/strands/base/model/load.py", "python/http/strands/base/pyproject.toml", + "python/http/strands/base/skills/fetcher.py", + "python/http/strands/capabilities/execution-limits/hooks/execution_limits.py", "python/http/strands/capabilities/memory/__init__.py", "python/http/strands/capabilities/memory/session.py", "python/http/strands/capabilities/payments/__init__.py", @@ -2228,8 +2257,11 @@ def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Opti {{#if (includes memoryProviders.[0].strategies "USER_PREFERENCE")}} f"/users/{actor_id}/preferences": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} +{{#if (includes memoryProviders.[0].strategies "EPISODIC")}} + f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k=5, relevance_score=0.5), +{{/if}} {{#if (includes memoryProviders.[0].strategies "SUMMARIZATION")}} - f"/summaries/{actor_id}": RetrievalConfig(top_k=3, relevance_score=0.5), + f"/summaries/{actor_id}/{session_id}": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} } {{/if}} @@ -3247,8 +3279,11 @@ def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Opti {{#if (includes memoryProviders.[0].strategies "USER_PREFERENCE")}} f"/users/{actor_id}/preferences": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} +{{#if (includes memoryProviders.[0].strategies "EPISODIC")}} + f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k=5, relevance_score=0.5), +{{/if}} {{#if (includes memoryProviders.[0].strategies "SUMMARIZATION")}} - f"/summaries/{actor_id}": RetrievalConfig(top_k=3, relevance_score=0.5), + f"/summaries/{actor_id}/{session_id}": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} } {{/if}} @@ -5180,25 +5215,77 @@ Thumbs.db" exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/base/main.py should match snapshot 1`] = ` "from typing import Any +{{#if inlineFunctionTools}} +import json +from strands.tools.tools import PythonAgentTool +from strands.types.tools import ToolResult, ToolUse +{{/if}} from strands import Agent, tool +{{#if hasSkillsFetcher}} +from strands import AgentSkills +{{#if hasFetchedSkills}} +from skills.fetcher import resolve_s3_skills, resolve_git_skills +{{/if}} +{{#if (some gitSkills "credentialArn")}} +from bedrock_agentcore.services.identity import IdentityClient +{{/if}} +{{/if}} +import asyncio +{{#if hasShell}} +import subprocess +{{/if}} +{{#if hasFileOperations}} +import os +{{/if}} +{{#if hasExecutionLimits}} +from strands.tools.executors import SequentialToolExecutor +from strands.types.exceptions import EventLoopException +from hooks.execution_limits import ExecutionLimitExceeded, ExecutionLimitsHook +{{/if}} {{#if hasConfigBundle}} from strands.hooks import HookProvider, HookRegistry, BeforeInvocationEvent, BeforeToolCallEvent +{{/if}} +{{#if truncationStrategy}} +{{#if (eq truncationStrategy "sliding_window")}} +from strands.agent.conversation_manager.sliding_window_conversation_manager import SlidingWindowConversationManager +{{/if}} +{{#if (eq truncationStrategy "summarization")}} +from strands.agent.conversation_manager.summarizing_conversation_manager import SummarizingConversationManager +{{/if}} +{{else}} +from strands.agent.conversation_manager.null_conversation_manager import NullConversationManager +{{/if}} +{{#if hasConfigBundle}} from bedrock_agentcore.runtime.context import BedrockAgentCoreContext {{/if}} +{{#if hasBrowser}} +from strands_tools.browser import AgentCoreBrowser +{{/if}} +{{#if hasCodeInterpreter}} +from strands_tools.code_interpreter import AgentCoreCodeInterpreter +{{/if}} from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model {{#if hasGateway}} from mcp_client.client import get_all_gateway_mcp_clients -{{else}} -from mcp_client.client import get_streamable_http_mcp_client {{/if}} +{{#if remoteMcpTools}} +from mcp_client.client import get_all_remote_mcp_clients +{{/if}} +{{#unless (or hasGateway remoteMcpTools)}} +{{#unless isExportHarness}} +from mcp_client.client import get_streamable_http_mcp_client +{{/unless}} +{{/unless}} {{#if hasMemory}} from memory.session import get_memory_session_manager {{/if}} -{{#if needsOs}} +{{#unless hasFileOperations}} +{{#if (or needsOs (some gitSkills "credentialArn"))}} import os {{/if}} +{{/unless}} {{#if hasPayment}} from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT {{/if}} @@ -5206,22 +5293,35 @@ from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTE app = BedrockAgentCoreApp() log = app.logger -# Define a Streamable HTTP MCP Client +{{#if (or hasGateway remoteMcpTools)}} +# Define MCP clients for all configured MCP servers (gateways and/or remote MCP) +mcp_clients = [] {{#if hasGateway}} -mcp_clients = get_all_gateway_mcp_clients() +mcp_clients += get_all_gateway_mcp_clients() +{{/if}} +{{#if remoteMcpTools}} +mcp_clients += get_all_remote_mcp_clients() +{{/if}} {{else}} +{{#unless isExportHarness}} +# Define a Streamable HTTP MCP Client mcp_clients = [get_streamable_http_mcp_client()] +{{/unless}} {{/if}} +{{#if systemPromptText}} +DEFAULT_SYSTEM_PROMPT = """{{escapePyStr systemPromptText}}""" +{{else}} DEFAULT_SYSTEM_PROMPT = """ You are a helpful assistant. Use tools when appropriate. -{{#if needsOs}} +{{#if needsOs}}{{#unless isExportHarness}} You have access to the following mounted filesystems. Use file_read, file_write, and list_files with full absolute paths: {{#if sessionStorageMountPath}}- {{sessionStorageMountPath}}: ephemeral session storage (lost when session ends) {{/if}}{{#each efsMounts}}- {{mountPath}}: EFS persistent storage (persists across sessions and agent restarts) {{/each}}{{#each s3Mounts}}- {{mountPath}}: S3 Files persistent storage (durable, backed by S3) -{{/each}}{{/if}} +{{/each}}{{/unless}}{{/if}} """ +{{/if}} {{#if hasConfigBundle}} DEFAULT_TOOL_DESC = "Return the sum of two numbers" @@ -5230,6 +5330,30 @@ DEFAULT_TOOL_DESC = "Return the sum of two numbers" # Define a collection of tools used by the model tools = [] +{{#if inlineFunctionTools}} +# Inline function tools — stop the agent loop so the tool call streams back to the caller +def _make_inline_tool(name: str, spec: dict) -> PythonAgentTool: + def _handler(tool: ToolUse, **kwargs: Any) -> ToolResult: + kwargs.get("request_state", {})["stop_event_loop"] = True + return {"toolUseId": tool["toolUseId"], "status": "success", "content": [{"text": " "}]} + _handler.__name__ = name + return PythonAgentTool(tool_name=name, tool_spec=spec, tool_func=_handler) + +{{#each inlineFunctionTools}} +_INLINE_SPEC_{{snakeCase name}} = { + "name": "{{name}}", + "description": {{safeJson description}}, + "inputSchema": {"json": json.loads({{pyJsonStr inputSchema}}) }, +} +tools.append(_make_inline_tool("{{name}}", _INLINE_SPEC_{{snakeCase name}})) +{{/each}} + +_INLINE_FUNCTION_NAMES = { {{#each inlineFunctionTools}}"{{name}}"{{#unless @last}}, {{/unless}}{{/each}} } + +{{else}} +_INLINE_FUNCTION_NAMES = set() + +{{#unless isExportHarness}} # Define a simple function tool {{#if hasConfigBundle}} @tool(description=DEFAULT_TOOL_DESC) @@ -5241,7 +5365,116 @@ def add_numbers(a: int, b: int) -> int: return a+b tools.append(add_numbers) -{{#if needsOs}} +{{/unless}} +{{/if}} +{{#if hasBrowser}} +tools.append(AgentCoreBrowser({{#if browserIdentifier}}identifier="{{browserIdentifier}}"{{/if}}).browser) +{{/if}} +{{#if hasCodeInterpreter}} +tools.append(AgentCoreCodeInterpreter({{#if codeInterpreterIdentifier}}identifier="{{codeInterpreterIdentifier}}"{{/if}}).code_interpreter) +{{/if}} +{{#if hasShell}} +@tool +def shell(command: str, timeout: int = 300) -> dict: + """Execute a bash command and return the results. + + Args: + command: The bash command to execute + timeout: Timeout in seconds (default: 300) + + Returns: + Dict with stdout, stderr, and exit_code + """ + result = subprocess.run( + command, shell=True, capture_output=True, text=True, timeout=timeout + ) + return {"stdout": result.stdout, "stderr": result.stderr, "exit_code": result.returncode} + +tools.append(shell) +{{/if}} +{{#if hasFileOperations}} +@tool +def file_operations( + command: str, + path: str, + old_str: str = None, + new_str: str = None, + file_text: str = None, + insert_line: int = None, + view_range: list = None, +) -> str: + """Text editor tool for viewing and modifying files. + + Args: + command: The command to execute ("view", "str_replace", "create", "insert") + path: Path to the file or directory + old_str: Text to replace (for str_replace command) + new_str: Replacement text (for str_replace and insert commands) + file_text: Content for new file (for create command) + insert_line: Line number to insert after (for insert command) + view_range: [start_line, end_line] for viewing specific lines (for view command) + + Returns: + Result of the operation + """ + try: + if command == "view": + if not os.path.exists(path): + return f"Error: Path '{path}' does not exist" + if os.path.isdir(path): + return "\\n".join(os.listdir(path)) + with open(path) as f: + lines = f.read().splitlines() + if view_range: + start, end = view_range + start_idx = max(0, start - 1) + end_idx = len(lines) if end == -1 else min(len(lines), end) + lines = lines[start_idx:end_idx] + start_num = start_idx + 1 + else: + start_num = 1 + return "\\n".join(f"{start_num + i}: {line}" for i, line in enumerate(lines)) + elif command == "str_replace": + if old_str is None or new_str is None: + return "Error: str_replace requires both old_str and new_str parameters" + if not os.path.exists(path): + return f"Error: File '{path}' does not exist" + content = open(path).read() + if old_str not in content: + return "Error: Text not found in file" + count = content.count(old_str) + if count > 1: + return f"Error: Text appears {count} times in file. Please be more specific." + open(path, "w").write(content.replace(old_str, new_str, 1)) + return f"Successfully replaced text in '{path}'" + elif command == "create": + if file_text is None: + return "Error: create requires file_text parameter" + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + open(path, "w").write(file_text) + return f"Successfully created file '{path}'" + elif command == "insert": + if new_str is None or insert_line is None: + return "Error: insert requires both new_str and insert_line parameters" + if not os.path.exists(path): + return f"Error: File '{path}' does not exist" + lines = open(path).read().splitlines(True) + if insert_line == 0: + lines.insert(0, new_str + "\\n") + elif insert_line >= len(lines): + lines.append(new_str + "\\n") + else: + lines.insert(insert_line, new_str + "\\n") + open(path, "w").write("".join(lines)) + return f"Successfully inserted text in '{path}' at line {insert_line + 1}" + else: + return f"Error: Unknown command '{command}'" + except Exception as e: + return f"Error: {e}" + +tools.append(file_operations) +{{/if}} +{{#if needsOs}}{{#unless isExportHarness}} _MOUNT_PATHS = [ {{#if sessionStorageMountPath}}"{{sessionStorageMountPath}}",{{/if}} {{#each efsMounts}}"{{mountPath}}",{{/each}} @@ -5295,12 +5528,21 @@ def list_files(path: str) -> str: return f"Error listing '{path}': {e.strerror}" tools.extend([file_read, file_write, list_files]) -{{/if}} +{{/unless}}{{/if}} +{{#if (or hasGateway remoteMcpTools)}} +# Add MCP clients to tools +for mcp_client in mcp_clients: + if mcp_client: + tools.append(mcp_client) +{{else}} +{{#unless isExportHarness}} # Add MCP client to tools if available for mcp_client in mcp_clients: if mcp_client: tools.append(mcp_client) +{{/unless}} +{{/if}} {{#if hasConfigBundle}} @@ -5336,19 +5578,62 @@ class ConfigBundleHook(HookProvider): {{/if}} +def _make_conversation_manager(): +{{#if truncationStrategy}} +{{#if (eq truncationStrategy "sliding_window")}} +{{#if truncationConfig}} + return SlidingWindowConversationManager(**{{safeJson truncationConfig}}, per_turn=True) +{{else}} + return SlidingWindowConversationManager(per_turn=True) +{{/if}} +{{else}} +{{#if truncationConfig}} + return SummarizingConversationManager(**{{safeJson truncationConfig}}) +{{else}} + return SummarizingConversationManager() +{{/if}} +{{/if}} +{{else}} + return NullConversationManager() +{{/if}} + {{#if hasMemory}} {{#unless hasPayment}} def agent_factory(): cache = {} - def get_or_create_agent(session_id, user_id): - key = f"{session_id}/{user_id}" + def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugins=None{{/if}}): + {{#if actorId}} + _actor_id = "{{actorId}}" + {{else}} + _actor_id = user_id + {{/if}} + key = f"{session_id}/{_actor_id}" if key not in cache: cache[key] = Agent( model=load_model(), - session_manager=get_memory_session_manager(session_id, user_id), + session_manager=get_memory_session_manager(session_id, _actor_id), + conversation_manager=_make_conversation_manager(), system_prompt=DEFAULT_SYSTEM_PROMPT, - tools=tools{{#if hasConfigBundle}}, - hooks=[ConfigBundleHook()]{{/if}} + tools=tools, + {{#if hasSkillsFetcher}} + plugins=skill_plugins or None, + {{/if}} + {{#if hasExecutionLimits}} + tool_executor=SequentialToolExecutor(), + callback_handler=None, + {{/if}} + hooks=[ + {{#if hasExecutionLimits}} + ExecutionLimitsHook( + {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} + {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} + {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} + ), + {{/if}} + {{#if hasConfigBundle}} + ConfigBundleHook(), + {{/if}} + ], ) return cache[key] return get_or_create_agent @@ -5356,24 +5641,45 @@ get_or_create_agent = agent_factory() {{/unless}} {{else}} {{#if hasConfigBundle}} -def create_agent(): +def create_agent({{#if hasSkillsFetcher}}skill_plugins=None{{/if}}): return Agent( model=load_model(), system_prompt=DEFAULT_SYSTEM_PROMPT, tools=tools, + conversation_manager=_make_conversation_manager(), + {{#if hasSkillsFetcher}} + plugins=skill_plugins or None, + {{/if}} hooks=[ConfigBundleHook()], ) {{else}} {{#unless hasPayment}} _agent = None -def get_or_create_agent(): +def get_or_create_agent({{#if hasSkillsFetcher}}skill_plugins=None{{/if}}): global _agent if _agent is None: _agent = Agent( model=load_model(), system_prompt=DEFAULT_SYSTEM_PROMPT, tools=tools, + conversation_manager=_make_conversation_manager(), + {{#if hasSkillsFetcher}} + plugins=skill_plugins or None, + {{/if}} + {{#if hasExecutionLimits}} + tool_executor=SequentialToolExecutor(), + callback_handler=None, + {{/if}} + hooks=[ + {{#if hasExecutionLimits}} + ExecutionLimitsHook( + {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} + {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} + {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} + ), + {{/if}} + ], ) return _agent {{/unless}} @@ -5381,6 +5687,42 @@ def get_or_create_agent(): {{/if}} +def _extract_prompt(payload: dict): + """Accept harness-style messages[], tool_results[], or plain prompt string payloads.""" + if "messages" in payload: + return payload["messages"] + if "tool_results" in payload: + return [{"role": "user", "content": [{"toolResult": { + "toolUseId": tr["toolUseId"], + "status": tr.get("status", "success"), + "content": tr.get("content", []), + }} for tr in payload["tool_results"]]}] + return payload.get("prompt", "") + + +def _has_inline_function_call(messages) -> bool: + """Return True if messages contains an assistant toolUse for an inline function tool.""" + if not _INLINE_FUNCTION_NAMES or not isinstance(messages, list): + return False + for msg in messages: + if msg.get("role") == "assistant": + for block in msg.get("content", []): + if isinstance(block, dict) and block.get("toolUse", {}).get("name") in _INLINE_FUNCTION_NAMES: + return True + return False + + +def _is_inline_function_call(event: dict) -> bool: + """Check if a contentBlockStart event is for an inline function tool.""" + if not _INLINE_FUNCTION_NAMES: + return False + cbs = event.get("contentBlockStart", {}) + start = cbs.get("start", {}) + tool_use = start.get("toolUse") if isinstance(start, dict) else None + return tool_use is not None and tool_use.get("name") in _INLINE_FUNCTION_NAMES + + + @app.entrypoint async def invoke(payload, context): log.info("Invoking Agent.....") @@ -5392,23 +5734,52 @@ async def invoke(payload, context): payments_plugin = create_payments_plugin(user_id, instrument_id, session_id) plugins = [payments_plugin] if payments_plugin else [] {{/if}} +{{#if hasSkillsFetcher}} + skill_paths = [{{#each pathSkills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}] + {{#if s3Skills}} + s3_skill_sources = [{{#each s3Skills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}] + skill_paths.extend(await asyncio.to_thread(resolve_s3_skills, s3_skill_sources, None)) + {{/if}} + {{#if gitSkills}} + git_skill_sources = [ + {{#each gitSkills}} + dict(url={{safeJson this.url}}{{#if this.path}}, path={{safeJson this.path}}{{/if}}{{#if this.credentialArn}}, credentialArn={{safeJson this.credentialArn}}{{#if this.username}}, username={{safeJson this.username}}{{/if}}{{/if}}), + {{/each}} + ] + {{#if (some gitSkills "credentialArn")}} + _git_identity_client = IdentityClient(os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1"))) + {{else}} + _git_identity_client = None + {{/if}} + skill_paths.extend(await asyncio.to_thread(resolve_git_skills, git_skill_sources, _git_identity_client)) + {{/if}} + _skill_plugins = [AgentSkills(skills=skill_paths)] if skill_paths else [] +{{/if}} {{#if hasMemory}} {{#if hasPayment}} mem_session_id = getattr(context, 'session_id', 'default-session') + {{#if actorId}} + mem_user_id = "{{actorId}}" + {{else}} mem_user_id = getattr(context, 'user_id', 'default-user') + {{/if}} agent = Agent( model=load_model(), session_manager=get_memory_session_manager(mem_session_id, mem_user_id), system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, tools=tools, - plugins=plugins,{{#if hasConfigBundle}} + plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}} hooks=[ConfigBundleHook()],{{/if}} ) {{else}} session_id = getattr(context, 'session_id', 'default-session') + {{#if actorId}} + user_id = "{{actorId}}" + {{else}} user_id = getattr(context, 'user_id', 'default-user') - agent = get_or_create_agent(session_id, user_id) + {{/if}} + agent = get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}}) {{/if}} {{else}} {{#if hasPayment}} @@ -5416,25 +5787,100 @@ async def invoke(payload, context): model=load_model(), system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, tools=tools, - plugins=plugins,{{#if hasConfigBundle}} + plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}} hooks=[ConfigBundleHook()],{{/if}} ) {{else}} {{#if hasConfigBundle}} - agent = create_agent() + agent = create_agent({{#if hasSkillsFetcher}}_skill_plugins{{/if}}) {{else}} - agent = get_or_create_agent() + agent = get_or_create_agent({{#if hasSkillsFetcher}}_skill_plugins{{/if}}) {{/if}} {{/if}} {{/if}} - # Execute and format response - stream = agent.stream_async(payload.get("prompt")) + prompt = _extract_prompt(payload) - async for event in stream: - # Handle Text parts of the response - if "data" in event and isinstance(event["data"], str): - yield event["data"] + {{#if inlineFunctionTools}} + # If Turn 2 carries the harness-style assistant(toolUse)+user(toolResult) pair, + # strip the placeholder turn Strands stored during Turn 1 so the real toolResult + # is injected cleanly — same protocol as the harness runtime. + if _has_inline_function_call(prompt): + msgs = agent.messages + if len(msgs) >= 2 and any("toolResult" in b for b in msgs[-1].get("content", [])): + del msgs[-2:] + {{/if}} + + {{#if hasExecutionLimits}} + timeout_seconds = {{#if timeoutSeconds}}{{timeoutSeconds}}{{else}}None{{/if}} + timeout_fired = False + watchdog_task = None + if timeout_seconds is not None: + async def _timeout_watchdog(): + nonlocal timeout_fired + await asyncio.sleep(timeout_seconds) + timeout_fired = True + agent.cancel() + watchdog_task = asyncio.create_task(_timeout_watchdog()) + + try: + {{#if inlineFunctionTools}} + hit_inline_function = False + {{/if}} + async for event in agent.stream_async( + prompt, + ): + if not isinstance(event, dict) or "event" not in event: + continue + cbs = event["event"].get("contentBlockStart") + if cbs is not None and not cbs.get("start"): + continue + {{#if inlineFunctionTools}} + if not hit_inline_function: + hit_inline_function = _is_inline_function_call(event["event"]) + {{/if}} + yield event + {{#if inlineFunctionTools}} + if hit_inline_function and "messageStop" in event["event"]: + return + {{/if}} + + if timeout_fired: + yield {"event": {"messageStop": {"stopReason": "timeout_exceeded"}}} + except EventLoopException as e: + if isinstance(e.original_exception, ExecutionLimitExceeded): + yield {"event": {"messageStop": {"stopReason": str(e.original_exception)}}} + return + raise + finally: + if watchdog_task is not None: + watchdog_task.cancel() + try: + await watchdog_task + except asyncio.CancelledError: + pass + {{else}} + {{#if inlineFunctionTools}} + hit_inline_function = False + {{/if}} + async for event in agent.stream_async( + prompt, + ): + if not isinstance(event, dict) or "event" not in event: + continue + cbs = event["event"].get("contentBlockStart") + if cbs is not None and not cbs.get("start"): + continue + {{#if inlineFunctionTools}} + if not hit_inline_function: + hit_inline_function = _is_inline_function_call(event["event"]) + {{/if}} + yield event + {{#if inlineFunctionTools}} + if hit_inline_function and "messageStop" in event["event"]: + return + {{/if}} + {{/if}} if __name__ == "__main__": @@ -5479,10 +5925,14 @@ def _get_bearer_token_{{snakeCase name}}(*, access_token: str): {{#each gatewayProviders}} def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: """Returns an MCP Client connected to the {{name}} gateway.""" + {{#if hardcodedUrl}} + url = {{safeJson hardcodedUrl}} + {{else}} url = os.environ.get("{{envVarName}}") if not url: logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") return None + {{/if}} {{#if (eq authType "AWS_IAM")}} return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION"))), prefix="{{snakeCase name}}") {{else if (eq authType "CUSTOM_JWT")}} @@ -5503,7 +5953,41 @@ def get_all_gateway_mcp_clients() -> list[MCPClient]: clients.append(client) {{/each}} return clients -{{else}} +{{/if}} +{{#if remoteMcpTools}} +{{#if (some remoteMcpTools "headerCredentials")}} +from bedrock_agentcore.identity.auth import requires_api_key +{{/if}} +{{#each remoteMcpTools}} +{{#if headerCredentials}} +{{#each headerCredentials}} +@requires_api_key(provider_name="{{credentialName}}") +def _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(api_key: str) -> str: + """Fetch {{headerKey}} credential for {{../name}} from AgentCore Identity.""" + return api_key + +{{/each}} +{{/if}} +def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: + """Returns an MCP Client for the {{name}} remote MCP server.""" + url = {{safeJson url}} + {{#if headerCredentials}} + if os.getenv("LOCAL_DEV") == "1": + headers = { {{#each headerCredentials}}{{safeJson headerKey}}: os.environ.get("{{envVarName}}", ""){{#unless @last}}, {{/unless}}{{/each}} } + else: + headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(){{#unless @last}}, {{/unless}}{{/each}} } + return MCPClient(lambda: streamablehttp_client(url, headers=headers)) + {{else}} + return MCPClient(lambda: streamablehttp_client(url)) + {{/if}} + +{{/each}} +def get_all_remote_mcp_clients() -> list[MCPClient]: + """Returns all configured remote MCP clients.""" + clients = [{{#each remoteMcpTools}}get_{{snakeCase name}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] + return [c for c in clients if c is not None] +{{/if}} +{{#unless (or hasGateway remoteMcpTools)}} {{#if isVpc}} # VPC mode: external MCP endpoints are not reachable without a NAT gateway. # Add an AgentCore Gateway with \`agentcore add gateway\`, or configure your own endpoint below. @@ -5512,6 +5996,7 @@ def get_streamable_http_mcp_client() -> MCPClient | None: """No MCP server configured. Add a gateway with \`agentcore add gateway\`.""" return None {{else}} +{{#unless isExportHarness}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" @@ -5519,8 +6004,9 @@ def get_streamable_http_mcp_client() -> MCPClient: """Returns an MCP Client compatible with Strands""" # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT)) +{{/unless}} {{/if}} -{{/if}} +{{/unless}} " `; @@ -5536,7 +6022,7 @@ from strands.models.bedrock import BedrockModel def load_model() -> BedrockModel: """Get Bedrock model client using IAM credentials.""" - return BedrockModel(model_id="global.anthropic.claude-sonnet-4-5-20250929-v1:0") + return BedrockModel(model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}") {{/if}} {{#if (eq modelProvider "Anthropic")}} import os @@ -5612,7 +6098,7 @@ def load_model() -> OpenAIModel: """Get authenticated OpenAI model client.""" return OpenAIModel( client_args={"api_key": _get_api_key()}, - model_id="gpt-4.1", + model_id="{{#if modelId}}{{modelId}}{{else}}gpt-4.1{{/if}}", ) {{/if}} {{#if (eq modelProvider "Gemini")}} @@ -5650,7 +6136,7 @@ def load_model() -> GeminiModel: """Get authenticated Gemini model client.""" return GeminiModel( client_args={"api_key": _get_api_key()}, - model_id="gemini-2.5-flash", + model_id="{{#if modelId}}{{modelId}}{{else}}gemini-2.5-flash{{/if}}", ) {{/if}} " @@ -5676,7 +6162,10 @@ dependencies = [ {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", {{/if}}"strands-agents >= 1.15.0", - {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", + {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools >= 0.1.0", + {{/if}}{{#if hasBrowser}}"nest-asyncio >= 1.5.0", + "playwright >= 1.42.0", + {{/if}}{{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", {{/if}}{{/if}} ] @@ -5685,6 +6174,347 @@ packages = ["."] " `; +exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/base/skills/fetcher.py should match snapshot 1`] = ` +""""Skill fetcher — downloads s3/git skills to local filesystem on first use. + +Resolved paths are passed to AgentSkills(skills=...) in main.py. +Cache directory: /.agents/skills/ — an absolute path under the system temp +directory (honors $TMPDIR, defaults to /tmp). The runtime working directory (e.g. +/var/task in a CodeZip runtime) is read-only, so the cache must live somewhere +guaranteed-writable. +""" + +import base64 +import hashlib +import json +import logging +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +_SKILLS_BASE = Path(tempfile.gettempdir()) / ".agents" / "skills" +_GIT_TIMEOUT = 60 +_S3_MAX_SIZE_BYTES = 1 * 1024 * 1024 * 1024 # 1 GB + + +def _stable_hash(value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest()[:12] + + +def _cleanup(path: Path) -> None: + """Remove a partially-created skill directory so retries don't see stale state.""" + shutil.rmtree(path, ignore_errors=True) + + +def _read_map(type_dir: Path) -> dict: + map_file = type_dir / ".map.json" + return json.loads(map_file.read_text()) if map_file.exists() else {} + + +def _write_map(type_dir: Path, mapping: dict) -> None: + type_dir.mkdir(parents=True, exist_ok=True) + (type_dir / ".map.json").write_text(json.dumps(mapping)) + + +def _resolve_cached(type_dir: Path, source_hash: str) -> Optional[str]: + """Return the cached skill directory for a source hash, or None if not on disk.""" + mapping = _read_map(type_dir) + dir_name = mapping.get(source_hash) + if dir_name and (type_dir / dir_name).exists(): + return str(type_dir / dir_name) + return None + + +def _read_skill_name(skill_dir: Path) -> str: + """Extract the skill name from SKILL.md YAML frontmatter.""" + content = (skill_dir / "SKILL.md").read_text() + if not content.startswith("---"): + raise ValueError(f"SKILL.md in {skill_dir} has no YAML frontmatter (must start with ---)") + parts = content.split("---", 2) + if len(parts) < 3: + raise ValueError(f"SKILL.md in {skill_dir} has malformed frontmatter (missing closing ---)") + for line in parts[1].strip().splitlines(): + if line.startswith("name:"): + name = line[len("name:"):].strip().strip("\\"'") + if name: + return name + raise ValueError(f"SKILL.md in {skill_dir} is missing a 'name' field in frontmatter") + + +def _pick_dir_name(type_dir: Path, name: str, source_hash: str) -> str: + """Pick a unique directory name, appending a hash suffix on collision.""" + if not (type_dir / name).exists(): + return name + return f"{name}-{source_hash[:8]}" + + +def _rename_and_cache_skill(type_dir: Path, temp_dir: Path, source_hash: str, skill_root: Path, + source_label: str = "") -> Path: + """Validate SKILL.md, rename the temp dir to the skill's declared name, and update the map. + + Raises ValueError if SKILL.md is missing or has invalid frontmatter. + """ + if not (skill_root / "SKILL.md").exists(): + _cleanup(temp_dir) + hint = f" (source: {source_label})" if source_label else "" + raise ValueError(f"No SKILL.md found in fetched skill{hint}") + + name = _read_skill_name(skill_root) + dir_name = _pick_dir_name(type_dir, name, source_hash) + final_dir = type_dir / dir_name + if final_dir != temp_dir: + temp_dir.rename(final_dir) + + mapping = _read_map(type_dir) + mapping[source_hash] = dir_name + _write_map(type_dir, mapping) + return final_dir + + +def _fetch_s3_skill(source: str, s3_client=None) -> Path: + """Download an s3:// skill prefix and return the local directory.""" + uri = source if source.endswith("/") else source + "/" + source_hash = _stable_hash(uri) + type_dir = _SKILLS_BASE / "s3" + + cached = _resolve_cached(type_dir, source_hash) + if cached: + return Path(cached) + + import boto3 + client = s3_client or boto3.client("s3") + bucket, _, prefix = uri[len("s3://"):].partition("/") + if not bucket: + raise ValueError(f"Invalid S3 URI (no bucket): {uri}") + + temp_dir = type_dir / source_hash + _cleanup(temp_dir) + temp_dir.mkdir(parents=True, exist_ok=True) + temp_root = temp_dir.resolve() + + paginator = client.get_paginator("list_objects_v2") + total = 0 + for page in paginator.paginate(Bucket=bucket, Prefix=prefix): + for obj in page.get("Contents", []): + total += obj["Size"] + if total > _S3_MAX_SIZE_BYTES: + _cleanup(temp_dir) + raise ValueError(f"S3 skill {uri} exceeds 1 GB size limit") + rel = obj["Key"][len(prefix):].lstrip("/") + if not rel: + continue + dest = (temp_dir / rel).resolve() + if dest != temp_root and not str(dest).startswith(str(temp_root) + os.sep): + _cleanup(temp_dir) + raise ValueError(f"Path traversal detected in S3 key: {obj['Key']}") + dest.parent.mkdir(parents=True, exist_ok=True) + client.download_file(bucket, obj["Key"], str(dest)) + + if total == 0: + _cleanup(temp_dir) + raise ValueError(f"No files found at S3 URI: {uri}") + + return _rename_and_cache_skill(type_dir, temp_dir, source_hash, temp_dir, source_label=uri) + + +def _resolve_credential_arn(credential_arn: str, identity_client) -> str: + """Resolve a Token Vault API-key credential ARN to its secret value via AgentCore Identity. + + ARN format: arn:

:bedrock-agentcore:::token-vault//apikeycredentialprovider/ + """ + from bedrock_agentcore.runtime.context import BedrockAgentCoreContext # noqa: PLC0415 + + provider_name = credential_arn.rsplit("/", 1)[-1] + if not provider_name: + raise ValueError(f"Invalid credential ARN: {credential_arn}") + workload_token = BedrockAgentCoreContext.get_workload_access_token() + if not workload_token: + raise ValueError("Credential ARN resolution requires a workload access token") + api_key = identity_client.dp_client.get_resource_api_key( + resourceCredentialProviderName=provider_name, + workloadIdentityToken=workload_token, + )["apiKey"] + if not api_key: + raise ValueError(f"Identity returned empty API key for provider: {provider_name}") + return api_key + + +def _build_git_auth_env(credential_arn: Optional[str], username: Optional[str], identity_client=None) -> dict: + """Build GIT_CONFIG_* env vars for HTTP Basic auth using a Token Vault credential ARN. + + Uses env vars instead of -c args to avoid leaking credentials in /proc/*/cmdline, + and so auth propagates to sub-commands (e.g. sparse-checkout triggering a fetch). + """ + if not credential_arn or not identity_client: + return {} + password = _resolve_credential_arn(credential_arn, identity_client) + user = username or "oauth2" + encoded = base64.b64encode(f"{user}:{password}".encode()).decode() + return { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.extraHeader", + "GIT_CONFIG_VALUE_0": f"Authorization: Basic {encoded}", + } + + +def _fetch_git_skill(url: str, skill_path: str = "", credential_arn: Optional[str] = None, + username: Optional[str] = None, identity_client=None) -> Path: + """Shallow-clone a git skill repository and return the local skill directory. + + Returns the directory containing SKILL.md (the subdir itself for sparse checkouts). + """ + if skill_path and (os.path.isabs(skill_path) or ".." in Path(skill_path).parts): + raise ValueError(f"Path traversal detected in skill path: {skill_path}") + + source_hash = _stable_hash(f"{url}:{skill_path}") + type_dir = _SKILLS_BASE / "git" + + cached = _resolve_cached(type_dir, source_hash) + if cached: + return Path(cached) / skill_path if skill_path else Path(cached) + + temp_dir = type_dir / source_hash + _cleanup(temp_dir) + temp_dir.mkdir(parents=True, exist_ok=True) + + extra_env = _build_git_auth_env(credential_arn, username, identity_client) + git_env = {**os.environ, **extra_env} if extra_env else None + + try: + if skill_path: + subprocess.run( + ["git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", url, str(temp_dir)], + check=True, timeout=_GIT_TIMEOUT, capture_output=True, env=git_env, + ) + subprocess.run( + ["git", "sparse-checkout", "set", skill_path], + check=True, timeout=_GIT_TIMEOUT, capture_output=True, cwd=str(temp_dir), env=git_env, + ) + else: + subprocess.run( + ["git", "clone", "--depth", "1", url, str(temp_dir)], + check=True, timeout=_GIT_TIMEOUT, capture_output=True, env=git_env, + ) + except Exception: + _cleanup(temp_dir) + raise + + if skill_path and not (temp_dir / skill_path).exists(): + _cleanup(temp_dir) + raise ValueError(f"Skill path '{skill_path}' not found in repository '{url}'") + + # SKILL.md lives inside the subdir for sparse checkouts. + skill_root = temp_dir / skill_path if skill_path else temp_dir + label = f"{url}:{skill_path}" if skill_path else url + final_dir = _rename_and_cache_skill(type_dir, temp_dir, source_hash, skill_root, source_label=label) + return final_dir / skill_path if skill_path else final_dir + + +def resolve_s3_skills(sources: list, s3_client=None) -> list: + """Resolve s3:// skill URIs to local filesystem paths. + + Any fetch failure raises and fails the invocation — a partial skill set + would silently run the agent without capabilities the harness declared. + """ + paths = [] + for uri in sources: + try: + skill_dir = _fetch_s3_skill(uri, s3_client) + except Exception as e: + raise ValueError(f"Failed to resolve S3 skill '{uri}': {e}") from e + paths.append(str(skill_dir.resolve())) + return paths + + +def resolve_git_skills(sources: list, identity_client=None) -> list: + """Resolve git skill dicts to local filesystem paths. + + Each source is a dict with keys: url (required), path (optional), + credentialArn (optional), username (optional). + + Any fetch failure raises and fails the invocation — a partial skill set + would silently run the agent without capabilities the harness declared. + """ + paths = [] + for source in sources: + try: + skill_dir = _fetch_git_skill( + url=source["url"], + skill_path=source.get("path") or "", + credential_arn=source.get("credentialArn"), + username=source.get("username"), + identity_client=identity_client, + ) + except Exception as e: + raise ValueError(f"Failed to resolve git skill '{source.get('url', source)}': {e}") from e + paths.append(str(skill_dir.resolve())) + return paths +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/capabilities/execution-limits/hooks/execution_limits.py should match snapshot 1`] = ` +"import time +from typing import Optional + +from strands.hooks import BeforeModelCallEvent +from strands.hooks.registry import HookProvider, HookRegistry +from strands.types.exceptions import EventLoopException + + +class ExecutionLimitExceeded(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + + +class ExecutionLimitsHook(HookProvider): + def __init__( + self, + max_iterations: Optional[int] = None, + max_tokens: Optional[int] = None, + timeout_seconds: Optional[float] = None, + ) -> None: + self._max_iterations = max_iterations + self._max_tokens = max_tokens + self._timeout_seconds = timeout_seconds + self._iteration_count = 0 + self._start_time = time.monotonic() + + def register_hooks(self, registry: HookRegistry, **kwargs) -> None: + registry.add_callback(BeforeModelCallEvent, self._check_limits) + + def _check_limits(self, event: BeforeModelCallEvent) -> None: + self._iteration_count += 1 + + if self._max_iterations is not None and self._iteration_count > self._max_iterations: + raise EventLoopException( + ExecutionLimitExceeded(f"Max iterations exceeded: {self._max_iterations}") + ) + + if self._timeout_seconds is not None: + elapsed = time.monotonic() - self._start_time + if elapsed > self._timeout_seconds: + raise EventLoopException( + ExecutionLimitExceeded( + f"Timeout exceeded: {self._timeout_seconds}s (elapsed {elapsed:.1f}s)" + ) + ) + + if self._max_tokens is not None: + used = event.agent.event_loop_metrics.accumulated_usage.get("outputTokens", 0) + if used >= self._max_tokens: + raise EventLoopException( + ExecutionLimitExceeded( + f"Max output tokens exceeded: {used}/{self._max_tokens}" + ) + ) +" +`; + exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/capabilities/memory/__init__.py should match snapshot 1`] = ` "# Package marker " @@ -5717,8 +6547,11 @@ def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Opti {{#if (includes memoryProviders.[0].strategies "USER_PREFERENCE")}} f"/users/{actor_id}/preferences": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} +{{#if (includes memoryProviders.[0].strategies "EPISODIC")}} + f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k=5, relevance_score=0.5), +{{/if}} {{#if (includes memoryProviders.[0].strategies "SUMMARIZATION")}} - f"/summaries/{actor_id}": RetrievalConfig(top_k=3, relevance_score=0.5), + f"/summaries/{actor_id}/{session_id}": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} } {{/if}} @@ -6381,6 +7214,19 @@ When modifying JSON config files: 4. Use CloudFormation-safe names (alphanumeric, start with letter) 5. Run \`agentcore validate\` to verify changes +## Harness Export + +\`agentcore export harness\` converts a harness configuration into a deployable Strands Python agent under \`app//\`. + +**After every export, you MUST read \`app//EXPORT_NOTES.md\` before proceeding.** + +This file lists any manual follow-up items required before the agent will deploy or run correctly — missing files to create, IAM policies to add, or configuration steps the exporter could not automate. A clean export produces "No manual steps required." Complete every item in the file before running \`agentcore deploy\`. + +\`\`\`bash +agentcore export harness --name # generates app//EXPORT_NOTES.md +cat app//EXPORT_NOTES.md # read this before touching anything else +\`\`\` + ## CLI Commands | Command | Description | @@ -6388,6 +7234,7 @@ When modifying JSON config files: | \`agentcore create\` | Create a new project | | \`agentcore add \` | Add agent, memory, credential, gateway, evaluator, policy | | \`agentcore remove \` | Remove a resource | +| \`agentcore export harness\` | Export a harness to a Strands runtime agent | | \`agentcore dev\` | Run agent locally with hot-reload | | \`agentcore deploy\` | Deploy to AWS | | \`agentcore status\` | Show deployment status | diff --git a/src/assets/agents/AGENTS.md b/src/assets/agents/AGENTS.md index 2153de014..431ce6cc3 100644 --- a/src/assets/agents/AGENTS.md +++ b/src/assets/agents/AGENTS.md @@ -122,6 +122,19 @@ When modifying JSON config files: 4. Use CloudFormation-safe names (alphanumeric, start with letter) 5. Run `agentcore validate` to verify changes +## Harness Export + +`agentcore export harness` converts a harness configuration into a deployable Strands Python agent under `app//`. + +**After every export, you MUST read `app//EXPORT_NOTES.md` before proceeding.** + +This file lists any manual follow-up items required before the agent will deploy or run correctly — missing files to create, IAM policies to add, or configuration steps the exporter could not automate. A clean export produces "No manual steps required." Complete every item in the file before running `agentcore deploy`. + +```bash +agentcore export harness --name # generates app//EXPORT_NOTES.md +cat app//EXPORT_NOTES.md # read this before touching anything else +``` + ## CLI Commands | Command | Description | @@ -129,6 +142,7 @@ When modifying JSON config files: | `agentcore create` | Create a new project | | `agentcore add ` | Add agent, memory, credential, gateway, evaluator, policy | | `agentcore remove ` | Remove a resource | +| `agentcore export harness` | Export a harness to a Strands runtime agent | | `agentcore dev` | Run agent locally with hot-reload | | `agentcore deploy` | Deploy to AWS | | `agentcore status` | Show deployment status | diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index cb68c06a3..44fc565ec 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { AgentCoreStack } from '../lib/cdk-stack'; -import { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; +import { AgentCoreStack, type HarnessConfig } from '../lib/cdk-stack'; +import { ConfigIO, HarnessSpecSchema, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; import { App, type Environment } from 'aws-cdk-lib'; import * as path from 'path'; import * as fs from 'fs'; @@ -56,40 +56,61 @@ async function main() { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } - // Read harness configs for role creation. + // Read harness configs: the full validated spec drives the CFN resource; the + // role-scoped fields drive the IAM role + container build. const projectRoot = path.resolve(configRoot, '..'); - const harnessConfigs: { - name: string; - executionRoleArn?: string; - memoryName?: string; - containerUri?: string; - hasDockerfile?: boolean; - dockerfile?: string; - codeLocation?: string; - tools?: { type: string; name: string }[]; - apiKeyArn?: string; - efsAccessPoints?: { accessPointArn: string; mountPath: string }[]; - s3AccessPoints?: { accessPointArn: string; mountPath: string }[]; - apiFormat?: 'converse_stream' | 'responses' | 'chat_completions'; - }[] = []; - for (const entry of specAny.harnesses ?? []) { + + // Read non-S3 KB connector-config files and pass their parsed contents to the + // L3 verbatim. The L3 does not read files; it expects the parsed + // connectorParameters keyed by the data source's connectorConfigFile path. + const connectorParametersByFile: Record> = {}; + for (const kb of specAny.knowledgeBases ?? []) { + for (const ds of kb.dataSources ?? []) { + if (ds.type !== 'S3' && ds.connectorConfigFile) { + const abs = path.resolve(projectRoot, ds.connectorConfigFile); + try { + connectorParametersByFile[ds.connectorConfigFile] = JSON.parse(fs.readFileSync(abs, 'utf-8')); + } catch (err) { + throw new Error( + `Could not read connector config '${ds.connectorConfigFile}' for knowledge base '${kb.name}' at ${abs}: ${err instanceof Error ? err.message : err}` + ); + } + } + } + } + + // Harness is preview-gated. The CLI bundle bakes the preview flag at build time and + // forwards it to this child process via AGENTCORE_PREVIEW (see toolkit-lib/wrapper.ts). + // This app is built separately and cannot see that build-time define, so it gates on the + // env var. Absent/anything-but-'1' defaults to off so a stale harnesses[] entry in a + // non-preview build never synthesizes an AWS::BedrockAgentCore::Harness resource. + const previewEnabled = process.env.AGENTCORE_PREVIEW === '1'; + + const harnessConfigs: HarnessConfig[] = []; + for (const entry of previewEnabled ? (specAny.harnesses ?? []) : []) { const harnessDir = path.resolve(projectRoot, entry.path); const harnessPath = path.resolve(harnessDir, 'harness.json'); try { - const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8')); + const harnessSpec = HarnessSpecSchema.parse(JSON.parse(fs.readFileSync(harnessPath, 'utf-8'))); harnessConfigs.push({ name: entry.name, executionRoleArn: harnessSpec.executionRoleArn, - memoryName: harnessSpec.memory?.name, + // Only an `existing` memory ref carries a name to wire IAM against; managed memory is + // owned by the harness (no sibling) and disabled has none — both resolve to undefined. + memoryName: harnessSpec.memory?.mode === 'existing' ? harnessSpec.memory.name : undefined, containerUri: harnessSpec.containerUri, hasDockerfile: !!harnessSpec.dockerfile, dockerfile: harnessSpec.dockerfile, codeLocation: harnessSpec.dockerfile ? harnessDir : undefined, tools: harnessSpec.tools, + skills: harnessSpec.skills, apiKeyArn: harnessSpec.model?.apiKeyArn, efsAccessPoints: harnessSpec.efsAccessPoints, s3AccessPoints: harnessSpec.s3AccessPoints, apiFormat: harnessSpec.model?.apiFormat, + // Full spec + dir drive the AWS::BedrockAgentCore::Harness CFN resource. + spec: harnessSpec, + harnessDir, }); } catch (err) { throw new Error( @@ -156,6 +177,7 @@ async function main() { spec, mcpSpec, credentials, + connectorParametersByFile, harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, paymentSpec, env, diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index f16f84555..3dac0669d 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -6,24 +6,18 @@ import { type AgentCoreProjectSpec, type AgentCoreMcpSpec, type CustomJWTAuthorizerConfig, + type HarnessDeploymentConfig, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; -export interface HarnessConfig { - name: string; - executionRoleArn?: string; - memoryName?: string; - containerUri?: string; - hasDockerfile?: boolean; - dockerfile?: string; - codeLocation?: string; - tools?: { type: string; name: string }[]; - apiKeyArn?: string; - efsAccessPoints?: { accessPointArn: string; mountPath: string }[]; - s3AccessPoints?: { accessPointArn: string; mountPath: string }[]; -} +/** + * Harness deployment config: role-scoped fields (for IAM role + container build) + * plus the full validated spec + its config directory so the L3 construct can + * synthesize the AWS::BedrockAgentCore::Harness resource. + */ +export type HarnessConfig = HarnessDeploymentConfig; export interface PaymentConnectorSpec { name: string; @@ -59,6 +53,11 @@ export interface AgentCoreStackProps extends StackProps { * Harness role configurations. */ harnesses?: HarnessConfig[]; + /** + * Parsed connectorParameters for non-S3 KB data sources, keyed by + * connectorConfigFile path. Forwarded to AgentCoreApplication. + */ + connectorParametersByFile?: Record>; /** * Payment specifications with resolved credential provider ARNs. */ @@ -98,7 +97,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials, harnesses, paymentSpec } = props; + const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile, paymentSpec } = props; // Create AgentCoreApplication with all agents and harness roles // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -106,6 +105,12 @@ export class AgentCoreStack extends Stack { if (harnesses?.length) { appProps.harnesses = harnesses; } + if (connectorParametersByFile && Object.keys(connectorParametersByFile).length > 0) { + appProps.connectorParametersByFile = connectorParametersByFile; + } + if (credentials) { + appProps.credentials = credentials; + } this.application = new AgentCoreApplication(this, 'Application', appProps as any); // Create AgentCoreMcp if there are gateways configured diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index 7539c8c65..8db318ada 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -17,11 +17,11 @@ test('AgentCoreStack synthesizes with empty spec', () => { configBundles: [], policyEngines: [], payments: [], - configBundles: [], agentCoreGateways: [], mcpRuntimeTools: [], unassignedTargets: [], datasets: [], + knowledgeBases: [], }, }); const template = Template.fromStack(stack); diff --git a/src/assets/container/python/Dockerfile b/src/assets/container/python/Dockerfile index cb3569eff..265fd9a3e 100644 --- a/src/assets/container/python/Dockerfile +++ b/src/assets/container/python/Dockerfile @@ -1,5 +1,9 @@ FROM public.ecr.aws/docker/library/python:3.12-slim-trixie +{{#if gitSkills}} +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* + +{{/if}} RUN pip install --no-cache-dir uv ARG UV_DEFAULT_INDEX diff --git a/src/assets/python/a2a/strands/capabilities/memory/session.py b/src/assets/python/a2a/strands/capabilities/memory/session.py index 2b754424f..1a8b7e5a3 100644 --- a/src/assets/python/a2a/strands/capabilities/memory/session.py +++ b/src/assets/python/a2a/strands/capabilities/memory/session.py @@ -24,8 +24,11 @@ def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Opti {{#if (includes memoryProviders.[0].strategies "USER_PREFERENCE")}} f"/users/{actor_id}/preferences": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} +{{#if (includes memoryProviders.[0].strategies "EPISODIC")}} + f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k=5, relevance_score=0.5), +{{/if}} {{#if (includes memoryProviders.[0].strategies "SUMMARIZATION")}} - f"/summaries/{actor_id}": RetrievalConfig(top_k=3, relevance_score=0.5), + f"/summaries/{actor_id}/{session_id}": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} } {{/if}} diff --git a/src/assets/python/agui/strands/capabilities/memory/session.py b/src/assets/python/agui/strands/capabilities/memory/session.py index 2b754424f..1a8b7e5a3 100644 --- a/src/assets/python/agui/strands/capabilities/memory/session.py +++ b/src/assets/python/agui/strands/capabilities/memory/session.py @@ -24,8 +24,11 @@ def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Opti {{#if (includes memoryProviders.[0].strategies "USER_PREFERENCE")}} f"/users/{actor_id}/preferences": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} +{{#if (includes memoryProviders.[0].strategies "EPISODIC")}} + f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k=5, relevance_score=0.5), +{{/if}} {{#if (includes memoryProviders.[0].strategies "SUMMARIZATION")}} - f"/summaries/{actor_id}": RetrievalConfig(top_k=3, relevance_score=0.5), + f"/summaries/{actor_id}/{session_id}": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} } {{/if}} diff --git a/src/assets/python/http/strands/base/main.py b/src/assets/python/http/strands/base/main.py index 254550134..87a216c32 100644 --- a/src/assets/python/http/strands/base/main.py +++ b/src/assets/python/http/strands/base/main.py @@ -1,23 +1,75 @@ from typing import Any +{{#if inlineFunctionTools}} +import json +from strands.tools.tools import PythonAgentTool +from strands.types.tools import ToolResult, ToolUse +{{/if}} from strands import Agent, tool +{{#if hasSkillsFetcher}} +from strands import AgentSkills +{{#if hasFetchedSkills}} +from skills.fetcher import resolve_s3_skills, resolve_git_skills +{{/if}} +{{#if (some gitSkills "credentialArn")}} +from bedrock_agentcore.services.identity import IdentityClient +{{/if}} +{{/if}} +import asyncio +{{#if hasShell}} +import subprocess +{{/if}} +{{#if hasFileOperations}} +import os +{{/if}} +{{#if hasExecutionLimits}} +from strands.tools.executors import SequentialToolExecutor +from strands.types.exceptions import EventLoopException +from hooks.execution_limits import ExecutionLimitExceeded, ExecutionLimitsHook +{{/if}} {{#if hasConfigBundle}} from strands.hooks import HookProvider, HookRegistry, BeforeInvocationEvent, BeforeToolCallEvent +{{/if}} +{{#if truncationStrategy}} +{{#if (eq truncationStrategy "sliding_window")}} +from strands.agent.conversation_manager.sliding_window_conversation_manager import SlidingWindowConversationManager +{{/if}} +{{#if (eq truncationStrategy "summarization")}} +from strands.agent.conversation_manager.summarizing_conversation_manager import SummarizingConversationManager +{{/if}} +{{else}} +from strands.agent.conversation_manager.null_conversation_manager import NullConversationManager +{{/if}} +{{#if hasConfigBundle}} from bedrock_agentcore.runtime.context import BedrockAgentCoreContext {{/if}} +{{#if hasBrowser}} +from strands_tools.browser import AgentCoreBrowser +{{/if}} +{{#if hasCodeInterpreter}} +from strands_tools.code_interpreter import AgentCoreCodeInterpreter +{{/if}} from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model {{#if hasGateway}} from mcp_client.client import get_all_gateway_mcp_clients -{{else}} -from mcp_client.client import get_streamable_http_mcp_client {{/if}} +{{#if remoteMcpTools}} +from mcp_client.client import get_all_remote_mcp_clients +{{/if}} +{{#unless (or hasGateway remoteMcpTools)}} +{{#unless isExportHarness}} +from mcp_client.client import get_streamable_http_mcp_client +{{/unless}} +{{/unless}} {{#if hasMemory}} from memory.session import get_memory_session_manager {{/if}} -{{#if needsOs}} +{{#unless hasFileOperations}} +{{#if (or needsOs (some gitSkills "credentialArn"))}} import os {{/if}} +{{/unless}} {{#if hasPayment}} from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT {{/if}} @@ -25,22 +77,35 @@ app = BedrockAgentCoreApp() log = app.logger -# Define a Streamable HTTP MCP Client +{{#if (or hasGateway remoteMcpTools)}} +# Define MCP clients for all configured MCP servers (gateways and/or remote MCP) +mcp_clients = [] {{#if hasGateway}} -mcp_clients = get_all_gateway_mcp_clients() +mcp_clients += get_all_gateway_mcp_clients() +{{/if}} +{{#if remoteMcpTools}} +mcp_clients += get_all_remote_mcp_clients() +{{/if}} {{else}} +{{#unless isExportHarness}} +# Define a Streamable HTTP MCP Client mcp_clients = [get_streamable_http_mcp_client()] +{{/unless}} {{/if}} +{{#if systemPromptText}} +DEFAULT_SYSTEM_PROMPT = """{{escapePyStr systemPromptText}}""" +{{else}} DEFAULT_SYSTEM_PROMPT = """ You are a helpful assistant. Use tools when appropriate. -{{#if needsOs}} +{{#if needsOs}}{{#unless isExportHarness}} You have access to the following mounted filesystems. Use file_read, file_write, and list_files with full absolute paths: {{#if sessionStorageMountPath}}- {{sessionStorageMountPath}}: ephemeral session storage (lost when session ends) {{/if}}{{#each efsMounts}}- {{mountPath}}: EFS persistent storage (persists across sessions and agent restarts) {{/each}}{{#each s3Mounts}}- {{mountPath}}: S3 Files persistent storage (durable, backed by S3) -{{/each}}{{/if}} +{{/each}}{{/unless}}{{/if}} """ +{{/if}} {{#if hasConfigBundle}} DEFAULT_TOOL_DESC = "Return the sum of two numbers" @@ -49,6 +114,30 @@ # Define a collection of tools used by the model tools = [] +{{#if inlineFunctionTools}} +# Inline function tools — stop the agent loop so the tool call streams back to the caller +def _make_inline_tool(name: str, spec: dict) -> PythonAgentTool: + def _handler(tool: ToolUse, **kwargs: Any) -> ToolResult: + kwargs.get("request_state", {})["stop_event_loop"] = True + return {"toolUseId": tool["toolUseId"], "status": "success", "content": [{"text": " "}]} + _handler.__name__ = name + return PythonAgentTool(tool_name=name, tool_spec=spec, tool_func=_handler) + +{{#each inlineFunctionTools}} +_INLINE_SPEC_{{snakeCase name}} = { + "name": "{{name}}", + "description": {{safeJson description}}, + "inputSchema": {"json": json.loads({{pyJsonStr inputSchema}}) }, +} +tools.append(_make_inline_tool("{{name}}", _INLINE_SPEC_{{snakeCase name}})) +{{/each}} + +_INLINE_FUNCTION_NAMES = { {{#each inlineFunctionTools}}"{{name}}"{{#unless @last}}, {{/unless}}{{/each}} } + +{{else}} +_INLINE_FUNCTION_NAMES = set() + +{{#unless isExportHarness}} # Define a simple function tool {{#if hasConfigBundle}} @tool(description=DEFAULT_TOOL_DESC) @@ -60,7 +149,116 @@ def add_numbers(a: int, b: int) -> int: return a+b tools.append(add_numbers) -{{#if needsOs}} +{{/unless}} +{{/if}} +{{#if hasBrowser}} +tools.append(AgentCoreBrowser({{#if browserIdentifier}}identifier="{{browserIdentifier}}"{{/if}}).browser) +{{/if}} +{{#if hasCodeInterpreter}} +tools.append(AgentCoreCodeInterpreter({{#if codeInterpreterIdentifier}}identifier="{{codeInterpreterIdentifier}}"{{/if}}).code_interpreter) +{{/if}} +{{#if hasShell}} +@tool +def shell(command: str, timeout: int = 300) -> dict: + """Execute a bash command and return the results. + + Args: + command: The bash command to execute + timeout: Timeout in seconds (default: 300) + + Returns: + Dict with stdout, stderr, and exit_code + """ + result = subprocess.run( + command, shell=True, capture_output=True, text=True, timeout=timeout + ) + return {"stdout": result.stdout, "stderr": result.stderr, "exit_code": result.returncode} + +tools.append(shell) +{{/if}} +{{#if hasFileOperations}} +@tool +def file_operations( + command: str, + path: str, + old_str: str = None, + new_str: str = None, + file_text: str = None, + insert_line: int = None, + view_range: list = None, +) -> str: + """Text editor tool for viewing and modifying files. + + Args: + command: The command to execute ("view", "str_replace", "create", "insert") + path: Path to the file or directory + old_str: Text to replace (for str_replace command) + new_str: Replacement text (for str_replace and insert commands) + file_text: Content for new file (for create command) + insert_line: Line number to insert after (for insert command) + view_range: [start_line, end_line] for viewing specific lines (for view command) + + Returns: + Result of the operation + """ + try: + if command == "view": + if not os.path.exists(path): + return f"Error: Path '{path}' does not exist" + if os.path.isdir(path): + return "\n".join(os.listdir(path)) + with open(path) as f: + lines = f.read().splitlines() + if view_range: + start, end = view_range + start_idx = max(0, start - 1) + end_idx = len(lines) if end == -1 else min(len(lines), end) + lines = lines[start_idx:end_idx] + start_num = start_idx + 1 + else: + start_num = 1 + return "\n".join(f"{start_num + i}: {line}" for i, line in enumerate(lines)) + elif command == "str_replace": + if old_str is None or new_str is None: + return "Error: str_replace requires both old_str and new_str parameters" + if not os.path.exists(path): + return f"Error: File '{path}' does not exist" + content = open(path).read() + if old_str not in content: + return "Error: Text not found in file" + count = content.count(old_str) + if count > 1: + return f"Error: Text appears {count} times in file. Please be more specific." + open(path, "w").write(content.replace(old_str, new_str, 1)) + return f"Successfully replaced text in '{path}'" + elif command == "create": + if file_text is None: + return "Error: create requires file_text parameter" + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + open(path, "w").write(file_text) + return f"Successfully created file '{path}'" + elif command == "insert": + if new_str is None or insert_line is None: + return "Error: insert requires both new_str and insert_line parameters" + if not os.path.exists(path): + return f"Error: File '{path}' does not exist" + lines = open(path).read().splitlines(True) + if insert_line == 0: + lines.insert(0, new_str + "\n") + elif insert_line >= len(lines): + lines.append(new_str + "\n") + else: + lines.insert(insert_line, new_str + "\n") + open(path, "w").write("".join(lines)) + return f"Successfully inserted text in '{path}' at line {insert_line + 1}" + else: + return f"Error: Unknown command '{command}'" + except Exception as e: + return f"Error: {e}" + +tools.append(file_operations) +{{/if}} +{{#if needsOs}}{{#unless isExportHarness}} _MOUNT_PATHS = [ {{#if sessionStorageMountPath}}"{{sessionStorageMountPath}}",{{/if}} {{#each efsMounts}}"{{mountPath}}",{{/each}} @@ -114,12 +312,21 @@ def list_files(path: str) -> str: return f"Error listing '{path}': {e.strerror}" tools.extend([file_read, file_write, list_files]) -{{/if}} +{{/unless}}{{/if}} +{{#if (or hasGateway remoteMcpTools)}} +# Add MCP clients to tools +for mcp_client in mcp_clients: + if mcp_client: + tools.append(mcp_client) +{{else}} +{{#unless isExportHarness}} # Add MCP client to tools if available for mcp_client in mcp_clients: if mcp_client: tools.append(mcp_client) +{{/unless}} +{{/if}} {{#if hasConfigBundle}} @@ -155,19 +362,62 @@ def _override_tool_desc(self, event: BeforeToolCallEvent) -> None: {{/if}} +def _make_conversation_manager(): +{{#if truncationStrategy}} +{{#if (eq truncationStrategy "sliding_window")}} +{{#if truncationConfig}} + return SlidingWindowConversationManager(**{{safeJson truncationConfig}}, per_turn=True) +{{else}} + return SlidingWindowConversationManager(per_turn=True) +{{/if}} +{{else}} +{{#if truncationConfig}} + return SummarizingConversationManager(**{{safeJson truncationConfig}}) +{{else}} + return SummarizingConversationManager() +{{/if}} +{{/if}} +{{else}} + return NullConversationManager() +{{/if}} + {{#if hasMemory}} {{#unless hasPayment}} def agent_factory(): cache = {} - def get_or_create_agent(session_id, user_id): - key = f"{session_id}/{user_id}" + def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugins=None{{/if}}): + {{#if actorId}} + _actor_id = "{{actorId}}" + {{else}} + _actor_id = user_id + {{/if}} + key = f"{session_id}/{_actor_id}" if key not in cache: cache[key] = Agent( model=load_model(), - session_manager=get_memory_session_manager(session_id, user_id), + session_manager=get_memory_session_manager(session_id, _actor_id), + conversation_manager=_make_conversation_manager(), system_prompt=DEFAULT_SYSTEM_PROMPT, - tools=tools{{#if hasConfigBundle}}, - hooks=[ConfigBundleHook()]{{/if}} + tools=tools, + {{#if hasSkillsFetcher}} + plugins=skill_plugins or None, + {{/if}} + {{#if hasExecutionLimits}} + tool_executor=SequentialToolExecutor(), + callback_handler=None, + {{/if}} + hooks=[ + {{#if hasExecutionLimits}} + ExecutionLimitsHook( + {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} + {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} + {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} + ), + {{/if}} + {{#if hasConfigBundle}} + ConfigBundleHook(), + {{/if}} + ], ) return cache[key] return get_or_create_agent @@ -175,24 +425,45 @@ def get_or_create_agent(session_id, user_id): {{/unless}} {{else}} {{#if hasConfigBundle}} -def create_agent(): +def create_agent({{#if hasSkillsFetcher}}skill_plugins=None{{/if}}): return Agent( model=load_model(), system_prompt=DEFAULT_SYSTEM_PROMPT, tools=tools, + conversation_manager=_make_conversation_manager(), + {{#if hasSkillsFetcher}} + plugins=skill_plugins or None, + {{/if}} hooks=[ConfigBundleHook()], ) {{else}} {{#unless hasPayment}} _agent = None -def get_or_create_agent(): +def get_or_create_agent({{#if hasSkillsFetcher}}skill_plugins=None{{/if}}): global _agent if _agent is None: _agent = Agent( model=load_model(), system_prompt=DEFAULT_SYSTEM_PROMPT, tools=tools, + conversation_manager=_make_conversation_manager(), + {{#if hasSkillsFetcher}} + plugins=skill_plugins or None, + {{/if}} + {{#if hasExecutionLimits}} + tool_executor=SequentialToolExecutor(), + callback_handler=None, + {{/if}} + hooks=[ + {{#if hasExecutionLimits}} + ExecutionLimitsHook( + {{#if maxIterations}}max_iterations={{maxIterations}},{{/if}} + {{#if maxTokens}}max_tokens={{maxTokens}},{{/if}} + {{#if timeoutSeconds}}timeout_seconds={{timeoutSeconds}},{{/if}} + ), + {{/if}} + ], ) return _agent {{/unless}} @@ -200,6 +471,42 @@ def get_or_create_agent(): {{/if}} +def _extract_prompt(payload: dict): + """Accept harness-style messages[], tool_results[], or plain prompt string payloads.""" + if "messages" in payload: + return payload["messages"] + if "tool_results" in payload: + return [{"role": "user", "content": [{"toolResult": { + "toolUseId": tr["toolUseId"], + "status": tr.get("status", "success"), + "content": tr.get("content", []), + }} for tr in payload["tool_results"]]}] + return payload.get("prompt", "") + + +def _has_inline_function_call(messages) -> bool: + """Return True if messages contains an assistant toolUse for an inline function tool.""" + if not _INLINE_FUNCTION_NAMES or not isinstance(messages, list): + return False + for msg in messages: + if msg.get("role") == "assistant": + for block in msg.get("content", []): + if isinstance(block, dict) and block.get("toolUse", {}).get("name") in _INLINE_FUNCTION_NAMES: + return True + return False + + +def _is_inline_function_call(event: dict) -> bool: + """Check if a contentBlockStart event is for an inline function tool.""" + if not _INLINE_FUNCTION_NAMES: + return False + cbs = event.get("contentBlockStart", {}) + start = cbs.get("start", {}) + tool_use = start.get("toolUse") if isinstance(start, dict) else None + return tool_use is not None and tool_use.get("name") in _INLINE_FUNCTION_NAMES + + + @app.entrypoint async def invoke(payload, context): log.info("Invoking Agent.....") @@ -211,23 +518,52 @@ async def invoke(payload, context): payments_plugin = create_payments_plugin(user_id, instrument_id, session_id) plugins = [payments_plugin] if payments_plugin else [] {{/if}} +{{#if hasSkillsFetcher}} + skill_paths = [{{#each pathSkills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}] + {{#if s3Skills}} + s3_skill_sources = [{{#each s3Skills}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}] + skill_paths.extend(await asyncio.to_thread(resolve_s3_skills, s3_skill_sources, None)) + {{/if}} + {{#if gitSkills}} + git_skill_sources = [ + {{#each gitSkills}} + dict(url={{safeJson this.url}}{{#if this.path}}, path={{safeJson this.path}}{{/if}}{{#if this.credentialArn}}, credentialArn={{safeJson this.credentialArn}}{{#if this.username}}, username={{safeJson this.username}}{{/if}}{{/if}}), + {{/each}} + ] + {{#if (some gitSkills "credentialArn")}} + _git_identity_client = IdentityClient(os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1"))) + {{else}} + _git_identity_client = None + {{/if}} + skill_paths.extend(await asyncio.to_thread(resolve_git_skills, git_skill_sources, _git_identity_client)) + {{/if}} + _skill_plugins = [AgentSkills(skills=skill_paths)] if skill_paths else [] +{{/if}} {{#if hasMemory}} {{#if hasPayment}} mem_session_id = getattr(context, 'session_id', 'default-session') + {{#if actorId}} + mem_user_id = "{{actorId}}" + {{else}} mem_user_id = getattr(context, 'user_id', 'default-user') + {{/if}} agent = Agent( model=load_model(), session_manager=get_memory_session_manager(mem_session_id, mem_user_id), system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, tools=tools, - plugins=plugins,{{#if hasConfigBundle}} + plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}} hooks=[ConfigBundleHook()],{{/if}} ) {{else}} session_id = getattr(context, 'session_id', 'default-session') + {{#if actorId}} + user_id = "{{actorId}}" + {{else}} user_id = getattr(context, 'user_id', 'default-user') - agent = get_or_create_agent(session_id, user_id) + {{/if}} + agent = get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}}) {{/if}} {{else}} {{#if hasPayment}} @@ -235,25 +571,100 @@ async def invoke(payload, context): model=load_model(), system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, tools=tools, - plugins=plugins,{{#if hasConfigBundle}} + plugins=plugins{{#if hasSkillsFetcher}} + _skill_plugins{{/if}},{{#if hasConfigBundle}} hooks=[ConfigBundleHook()],{{/if}} ) {{else}} {{#if hasConfigBundle}} - agent = create_agent() + agent = create_agent({{#if hasSkillsFetcher}}_skill_plugins{{/if}}) {{else}} - agent = get_or_create_agent() + agent = get_or_create_agent({{#if hasSkillsFetcher}}_skill_plugins{{/if}}) {{/if}} {{/if}} {{/if}} - # Execute and format response - stream = agent.stream_async(payload.get("prompt")) + prompt = _extract_prompt(payload) + + {{#if inlineFunctionTools}} + # If Turn 2 carries the harness-style assistant(toolUse)+user(toolResult) pair, + # strip the placeholder turn Strands stored during Turn 1 so the real toolResult + # is injected cleanly — same protocol as the harness runtime. + if _has_inline_function_call(prompt): + msgs = agent.messages + if len(msgs) >= 2 and any("toolResult" in b for b in msgs[-1].get("content", [])): + del msgs[-2:] + {{/if}} + + {{#if hasExecutionLimits}} + timeout_seconds = {{#if timeoutSeconds}}{{timeoutSeconds}}{{else}}None{{/if}} + timeout_fired = False + watchdog_task = None + if timeout_seconds is not None: + async def _timeout_watchdog(): + nonlocal timeout_fired + await asyncio.sleep(timeout_seconds) + timeout_fired = True + agent.cancel() + watchdog_task = asyncio.create_task(_timeout_watchdog()) - async for event in stream: - # Handle Text parts of the response - if "data" in event and isinstance(event["data"], str): - yield event["data"] + try: + {{#if inlineFunctionTools}} + hit_inline_function = False + {{/if}} + async for event in agent.stream_async( + prompt, + ): + if not isinstance(event, dict) or "event" not in event: + continue + cbs = event["event"].get("contentBlockStart") + if cbs is not None and not cbs.get("start"): + continue + {{#if inlineFunctionTools}} + if not hit_inline_function: + hit_inline_function = _is_inline_function_call(event["event"]) + {{/if}} + yield event + {{#if inlineFunctionTools}} + if hit_inline_function and "messageStop" in event["event"]: + return + {{/if}} + + if timeout_fired: + yield {"event": {"messageStop": {"stopReason": "timeout_exceeded"}}} + except EventLoopException as e: + if isinstance(e.original_exception, ExecutionLimitExceeded): + yield {"event": {"messageStop": {"stopReason": str(e.original_exception)}}} + return + raise + finally: + if watchdog_task is not None: + watchdog_task.cancel() + try: + await watchdog_task + except asyncio.CancelledError: + pass + {{else}} + {{#if inlineFunctionTools}} + hit_inline_function = False + {{/if}} + async for event in agent.stream_async( + prompt, + ): + if not isinstance(event, dict) or "event" not in event: + continue + cbs = event["event"].get("contentBlockStart") + if cbs is not None and not cbs.get("start"): + continue + {{#if inlineFunctionTools}} + if not hit_inline_function: + hit_inline_function = _is_inline_function_call(event["event"]) + {{/if}} + yield event + {{#if inlineFunctionTools}} + if hit_inline_function and "messageStop" in event["event"]: + return + {{/if}} + {{/if}} if __name__ == "__main__": diff --git a/src/assets/python/http/strands/base/mcp_client/client.py b/src/assets/python/http/strands/base/mcp_client/client.py index 13dad314c..72987c456 100644 --- a/src/assets/python/http/strands/base/mcp_client/client.py +++ b/src/assets/python/http/strands/base/mcp_client/client.py @@ -29,10 +29,14 @@ def _get_bearer_token_{{snakeCase name}}(*, access_token: str): {{#each gatewayProviders}} def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: """Returns an MCP Client connected to the {{name}} gateway.""" + {{#if hardcodedUrl}} + url = {{safeJson hardcodedUrl}} + {{else}} url = os.environ.get("{{envVarName}}") if not url: logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") return None + {{/if}} {{#if (eq authType "AWS_IAM")}} return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION"))), prefix="{{snakeCase name}}") {{else if (eq authType "CUSTOM_JWT")}} @@ -53,7 +57,41 @@ def get_all_gateway_mcp_clients() -> list[MCPClient]: clients.append(client) {{/each}} return clients -{{else}} +{{/if}} +{{#if remoteMcpTools}} +{{#if (some remoteMcpTools "headerCredentials")}} +from bedrock_agentcore.identity.auth import requires_api_key +{{/if}} +{{#each remoteMcpTools}} +{{#if headerCredentials}} +{{#each headerCredentials}} +@requires_api_key(provider_name="{{credentialName}}") +def _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(api_key: str) -> str: + """Fetch {{headerKey}} credential for {{../name}} from AgentCore Identity.""" + return api_key + +{{/each}} +{{/if}} +def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: + """Returns an MCP Client for the {{name}} remote MCP server.""" + url = {{safeJson url}} + {{#if headerCredentials}} + if os.getenv("LOCAL_DEV") == "1": + headers = { {{#each headerCredentials}}{{safeJson headerKey}}: os.environ.get("{{envVarName}}", ""){{#unless @last}}, {{/unless}}{{/each}} } + else: + headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{snakeCase ../name}}_{{snakeCase headerKey}}_key(){{#unless @last}}, {{/unless}}{{/each}} } + return MCPClient(lambda: streamablehttp_client(url, headers=headers)) + {{else}} + return MCPClient(lambda: streamablehttp_client(url)) + {{/if}} + +{{/each}} +def get_all_remote_mcp_clients() -> list[MCPClient]: + """Returns all configured remote MCP clients.""" + clients = [{{#each remoteMcpTools}}get_{{snakeCase name}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] + return [c for c in clients if c is not None] +{{/if}} +{{#unless (or hasGateway remoteMcpTools)}} {{#if isVpc}} # VPC mode: external MCP endpoints are not reachable without a NAT gateway. # Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below. @@ -62,6 +100,7 @@ def get_streamable_http_mcp_client() -> MCPClient | None: """No MCP server configured. Add a gateway with `agentcore add gateway`.""" return None {{else}} +{{#unless isExportHarness}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" @@ -69,5 +108,6 @@ def get_streamable_http_mcp_client() -> MCPClient: """Returns an MCP Client compatible with Strands""" # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT)) +{{/unless}} {{/if}} -{{/if}} +{{/unless}} diff --git a/src/assets/python/http/strands/base/model/load.py b/src/assets/python/http/strands/base/model/load.py index 8954269e6..e1f013b89 100644 --- a/src/assets/python/http/strands/base/model/load.py +++ b/src/assets/python/http/strands/base/model/load.py @@ -4,7 +4,7 @@ def load_model() -> BedrockModel: """Get Bedrock model client using IAM credentials.""" - return BedrockModel(model_id="global.anthropic.claude-sonnet-4-5-20250929-v1:0") + return BedrockModel(model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}") {{/if}} {{#if (eq modelProvider "Anthropic")}} import os @@ -80,7 +80,7 @@ def load_model() -> OpenAIModel: """Get authenticated OpenAI model client.""" return OpenAIModel( client_args={"api_key": _get_api_key()}, - model_id="gpt-4.1", + model_id="{{#if modelId}}{{modelId}}{{else}}gpt-4.1{{/if}}", ) {{/if}} {{#if (eq modelProvider "Gemini")}} @@ -118,6 +118,6 @@ def load_model() -> GeminiModel: """Get authenticated Gemini model client.""" return GeminiModel( client_args={"api_key": _get_api_key()}, - model_id="gemini-2.5-flash", + model_id="{{#if modelId}}{{modelId}}{{else}}gemini-2.5-flash{{/if}}", ) {{/if}} diff --git a/src/assets/python/http/strands/base/pyproject.toml b/src/assets/python/http/strands/base/pyproject.toml index 3722d0ea9..bed35447f 100644 --- a/src/assets/python/http/strands/base/pyproject.toml +++ b/src/assets/python/http/strands/base/pyproject.toml @@ -17,7 +17,10 @@ dependencies = [ {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", {{/if}}"strands-agents >= 1.15.0", - {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", + {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools >= 0.1.0", + {{/if}}{{#if hasBrowser}}"nest-asyncio >= 1.5.0", + "playwright >= 1.42.0", + {{/if}}{{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", {{/if}}{{/if}} ] diff --git a/src/assets/python/http/strands/base/skills/fetcher.py b/src/assets/python/http/strands/base/skills/fetcher.py new file mode 100644 index 000000000..2f82cd6c2 --- /dev/null +++ b/src/assets/python/http/strands/base/skills/fetcher.py @@ -0,0 +1,279 @@ +"""Skill fetcher — downloads s3/git skills to local filesystem on first use. + +Resolved paths are passed to AgentSkills(skills=...) in main.py. +Cache directory: /.agents/skills/ — an absolute path under the system temp +directory (honors $TMPDIR, defaults to /tmp). The runtime working directory (e.g. +/var/task in a CodeZip runtime) is read-only, so the cache must live somewhere +guaranteed-writable. +""" + +import base64 +import hashlib +import json +import logging +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +_SKILLS_BASE = Path(tempfile.gettempdir()) / ".agents" / "skills" +_GIT_TIMEOUT = 60 +_S3_MAX_SIZE_BYTES = 1 * 1024 * 1024 * 1024 # 1 GB + + +def _stable_hash(value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest()[:12] + + +def _cleanup(path: Path) -> None: + """Remove a partially-created skill directory so retries don't see stale state.""" + shutil.rmtree(path, ignore_errors=True) + + +def _read_map(type_dir: Path) -> dict: + map_file = type_dir / ".map.json" + return json.loads(map_file.read_text()) if map_file.exists() else {} + + +def _write_map(type_dir: Path, mapping: dict) -> None: + type_dir.mkdir(parents=True, exist_ok=True) + (type_dir / ".map.json").write_text(json.dumps(mapping)) + + +def _resolve_cached(type_dir: Path, source_hash: str) -> Optional[str]: + """Return the cached skill directory for a source hash, or None if not on disk.""" + mapping = _read_map(type_dir) + dir_name = mapping.get(source_hash) + if dir_name and (type_dir / dir_name).exists(): + return str(type_dir / dir_name) + return None + + +def _read_skill_name(skill_dir: Path) -> str: + """Extract the skill name from SKILL.md YAML frontmatter.""" + content = (skill_dir / "SKILL.md").read_text() + if not content.startswith("---"): + raise ValueError(f"SKILL.md in {skill_dir} has no YAML frontmatter (must start with ---)") + parts = content.split("---", 2) + if len(parts) < 3: + raise ValueError(f"SKILL.md in {skill_dir} has malformed frontmatter (missing closing ---)") + for line in parts[1].strip().splitlines(): + if line.startswith("name:"): + name = line[len("name:"):].strip().strip("\"'") + if name: + return name + raise ValueError(f"SKILL.md in {skill_dir} is missing a 'name' field in frontmatter") + + +def _pick_dir_name(type_dir: Path, name: str, source_hash: str) -> str: + """Pick a unique directory name, appending a hash suffix on collision.""" + if not (type_dir / name).exists(): + return name + return f"{name}-{source_hash[:8]}" + + +def _rename_and_cache_skill(type_dir: Path, temp_dir: Path, source_hash: str, skill_root: Path, + source_label: str = "") -> Path: + """Validate SKILL.md, rename the temp dir to the skill's declared name, and update the map. + + Raises ValueError if SKILL.md is missing or has invalid frontmatter. + """ + if not (skill_root / "SKILL.md").exists(): + _cleanup(temp_dir) + hint = f" (source: {source_label})" if source_label else "" + raise ValueError(f"No SKILL.md found in fetched skill{hint}") + + name = _read_skill_name(skill_root) + dir_name = _pick_dir_name(type_dir, name, source_hash) + final_dir = type_dir / dir_name + if final_dir != temp_dir: + temp_dir.rename(final_dir) + + mapping = _read_map(type_dir) + mapping[source_hash] = dir_name + _write_map(type_dir, mapping) + return final_dir + + +def _fetch_s3_skill(source: str, s3_client=None) -> Path: + """Download an s3:// skill prefix and return the local directory.""" + uri = source if source.endswith("/") else source + "/" + source_hash = _stable_hash(uri) + type_dir = _SKILLS_BASE / "s3" + + cached = _resolve_cached(type_dir, source_hash) + if cached: + return Path(cached) + + import boto3 + client = s3_client or boto3.client("s3") + bucket, _, prefix = uri[len("s3://"):].partition("/") + if not bucket: + raise ValueError(f"Invalid S3 URI (no bucket): {uri}") + + temp_dir = type_dir / source_hash + _cleanup(temp_dir) + temp_dir.mkdir(parents=True, exist_ok=True) + temp_root = temp_dir.resolve() + + paginator = client.get_paginator("list_objects_v2") + total = 0 + for page in paginator.paginate(Bucket=bucket, Prefix=prefix): + for obj in page.get("Contents", []): + total += obj["Size"] + if total > _S3_MAX_SIZE_BYTES: + _cleanup(temp_dir) + raise ValueError(f"S3 skill {uri} exceeds 1 GB size limit") + rel = obj["Key"][len(prefix):].lstrip("/") + if not rel: + continue + dest = (temp_dir / rel).resolve() + if dest != temp_root and not str(dest).startswith(str(temp_root) + os.sep): + _cleanup(temp_dir) + raise ValueError(f"Path traversal detected in S3 key: {obj['Key']}") + dest.parent.mkdir(parents=True, exist_ok=True) + client.download_file(bucket, obj["Key"], str(dest)) + + if total == 0: + _cleanup(temp_dir) + raise ValueError(f"No files found at S3 URI: {uri}") + + return _rename_and_cache_skill(type_dir, temp_dir, source_hash, temp_dir, source_label=uri) + + +def _resolve_credential_arn(credential_arn: str, identity_client) -> str: + """Resolve a Token Vault API-key credential ARN to its secret value via AgentCore Identity. + + ARN format: arn:

:bedrock-agentcore:::token-vault//apikeycredentialprovider/ + """ + from bedrock_agentcore.runtime.context import BedrockAgentCoreContext # noqa: PLC0415 + + provider_name = credential_arn.rsplit("/", 1)[-1] + if not provider_name: + raise ValueError(f"Invalid credential ARN: {credential_arn}") + workload_token = BedrockAgentCoreContext.get_workload_access_token() + if not workload_token: + raise ValueError("Credential ARN resolution requires a workload access token") + api_key = identity_client.dp_client.get_resource_api_key( + resourceCredentialProviderName=provider_name, + workloadIdentityToken=workload_token, + )["apiKey"] + if not api_key: + raise ValueError(f"Identity returned empty API key for provider: {provider_name}") + return api_key + + +def _build_git_auth_env(credential_arn: Optional[str], username: Optional[str], identity_client=None) -> dict: + """Build GIT_CONFIG_* env vars for HTTP Basic auth using a Token Vault credential ARN. + + Uses env vars instead of -c args to avoid leaking credentials in /proc/*/cmdline, + and so auth propagates to sub-commands (e.g. sparse-checkout triggering a fetch). + """ + if not credential_arn or not identity_client: + return {} + password = _resolve_credential_arn(credential_arn, identity_client) + user = username or "oauth2" + encoded = base64.b64encode(f"{user}:{password}".encode()).decode() + return { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.extraHeader", + "GIT_CONFIG_VALUE_0": f"Authorization: Basic {encoded}", + } + + +def _fetch_git_skill(url: str, skill_path: str = "", credential_arn: Optional[str] = None, + username: Optional[str] = None, identity_client=None) -> Path: + """Shallow-clone a git skill repository and return the local skill directory. + + Returns the directory containing SKILL.md (the subdir itself for sparse checkouts). + """ + if skill_path and (os.path.isabs(skill_path) or ".." in Path(skill_path).parts): + raise ValueError(f"Path traversal detected in skill path: {skill_path}") + + source_hash = _stable_hash(f"{url}:{skill_path}") + type_dir = _SKILLS_BASE / "git" + + cached = _resolve_cached(type_dir, source_hash) + if cached: + return Path(cached) / skill_path if skill_path else Path(cached) + + temp_dir = type_dir / source_hash + _cleanup(temp_dir) + temp_dir.mkdir(parents=True, exist_ok=True) + + extra_env = _build_git_auth_env(credential_arn, username, identity_client) + git_env = {**os.environ, **extra_env} if extra_env else None + + try: + if skill_path: + subprocess.run( + ["git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", url, str(temp_dir)], + check=True, timeout=_GIT_TIMEOUT, capture_output=True, env=git_env, + ) + subprocess.run( + ["git", "sparse-checkout", "set", skill_path], + check=True, timeout=_GIT_TIMEOUT, capture_output=True, cwd=str(temp_dir), env=git_env, + ) + else: + subprocess.run( + ["git", "clone", "--depth", "1", url, str(temp_dir)], + check=True, timeout=_GIT_TIMEOUT, capture_output=True, env=git_env, + ) + except Exception: + _cleanup(temp_dir) + raise + + if skill_path and not (temp_dir / skill_path).exists(): + _cleanup(temp_dir) + raise ValueError(f"Skill path '{skill_path}' not found in repository '{url}'") + + # SKILL.md lives inside the subdir for sparse checkouts. + skill_root = temp_dir / skill_path if skill_path else temp_dir + label = f"{url}:{skill_path}" if skill_path else url + final_dir = _rename_and_cache_skill(type_dir, temp_dir, source_hash, skill_root, source_label=label) + return final_dir / skill_path if skill_path else final_dir + + +def resolve_s3_skills(sources: list, s3_client=None) -> list: + """Resolve s3:// skill URIs to local filesystem paths. + + Any fetch failure raises and fails the invocation — a partial skill set + would silently run the agent without capabilities the harness declared. + """ + paths = [] + for uri in sources: + try: + skill_dir = _fetch_s3_skill(uri, s3_client) + except Exception as e: + raise ValueError(f"Failed to resolve S3 skill '{uri}': {e}") from e + paths.append(str(skill_dir.resolve())) + return paths + + +def resolve_git_skills(sources: list, identity_client=None) -> list: + """Resolve git skill dicts to local filesystem paths. + + Each source is a dict with keys: url (required), path (optional), + credentialArn (optional), username (optional). + + Any fetch failure raises and fails the invocation — a partial skill set + would silently run the agent without capabilities the harness declared. + """ + paths = [] + for source in sources: + try: + skill_dir = _fetch_git_skill( + url=source["url"], + skill_path=source.get("path") or "", + credential_arn=source.get("credentialArn"), + username=source.get("username"), + identity_client=identity_client, + ) + except Exception as e: + raise ValueError(f"Failed to resolve git skill '{source.get('url', source)}': {e}") from e + paths.append(str(skill_dir.resolve())) + return paths diff --git a/src/assets/python/http/strands/capabilities/execution-limits/hooks/execution_limits.py b/src/assets/python/http/strands/capabilities/execution-limits/hooks/execution_limits.py new file mode 100644 index 000000000..057f348d8 --- /dev/null +++ b/src/assets/python/http/strands/capabilities/execution-limits/hooks/execution_limits.py @@ -0,0 +1,54 @@ +import time +from typing import Optional + +from strands.hooks import BeforeModelCallEvent +from strands.hooks.registry import HookProvider, HookRegistry +from strands.types.exceptions import EventLoopException + + +class ExecutionLimitExceeded(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + + +class ExecutionLimitsHook(HookProvider): + def __init__( + self, + max_iterations: Optional[int] = None, + max_tokens: Optional[int] = None, + timeout_seconds: Optional[float] = None, + ) -> None: + self._max_iterations = max_iterations + self._max_tokens = max_tokens + self._timeout_seconds = timeout_seconds + self._iteration_count = 0 + self._start_time = time.monotonic() + + def register_hooks(self, registry: HookRegistry, **kwargs) -> None: + registry.add_callback(BeforeModelCallEvent, self._check_limits) + + def _check_limits(self, event: BeforeModelCallEvent) -> None: + self._iteration_count += 1 + + if self._max_iterations is not None and self._iteration_count > self._max_iterations: + raise EventLoopException( + ExecutionLimitExceeded(f"Max iterations exceeded: {self._max_iterations}") + ) + + if self._timeout_seconds is not None: + elapsed = time.monotonic() - self._start_time + if elapsed > self._timeout_seconds: + raise EventLoopException( + ExecutionLimitExceeded( + f"Timeout exceeded: {self._timeout_seconds}s (elapsed {elapsed:.1f}s)" + ) + ) + + if self._max_tokens is not None: + used = event.agent.event_loop_metrics.accumulated_usage.get("outputTokens", 0) + if used >= self._max_tokens: + raise EventLoopException( + ExecutionLimitExceeded( + f"Max output tokens exceeded: {used}/{self._max_tokens}" + ) + ) diff --git a/src/assets/python/http/strands/capabilities/memory/session.py b/src/assets/python/http/strands/capabilities/memory/session.py index 125580900..a00b46666 100644 --- a/src/assets/python/http/strands/capabilities/memory/session.py +++ b/src/assets/python/http/strands/capabilities/memory/session.py @@ -24,8 +24,11 @@ def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Opti {{#if (includes memoryProviders.[0].strategies "USER_PREFERENCE")}} f"/users/{actor_id}/preferences": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} +{{#if (includes memoryProviders.[0].strategies "EPISODIC")}} + f"/episodes/{actor_id}/{session_id}": RetrievalConfig(top_k=5, relevance_score=0.5), +{{/if}} {{#if (includes memoryProviders.[0].strategies "SUMMARIZATION")}} - f"/summaries/{actor_id}": RetrievalConfig(top_k=3, relevance_score=0.5), + f"/summaries/{actor_id}/{session_id}": RetrievalConfig(top_k=3, relevance_score=0.5), {{/if}} } {{/if}} diff --git a/src/cli/aws/__tests__/agentcore-ab-tests.test.ts b/src/cli/aws/__tests__/agentcore-ab-tests.test.ts index 94dca3bdb..f0b21ccd8 100644 --- a/src/cli/aws/__tests__/agentcore-ab-tests.test.ts +++ b/src/cli/aws/__tests__/agentcore-ab-tests.test.ts @@ -136,15 +136,11 @@ describe('agentcore-ab-tests', () => { roleArn: 'arn:role', variants: [], evaluationConfig: { onlineEvaluationConfigArn: 'arn:eval' }, - trafficAllocationConfig: { routeOnHeader: { headerName: 'X-AB' } }, - maxDurationDays: 30, enableOnCreate: true, }); const body = JSON.parse(mockFetch.mock.calls[0]![1].body); expect(body.description).toBe('A description'); - expect(body.trafficAllocationConfig).toEqual({ routeOnHeader: { headerName: 'X-AB' } }); - expect(body.maxDurationDays).toBe(30); expect(body.enableOnCreate).toBe(true); }); @@ -249,14 +245,12 @@ describe('agentcore-ab-tests', () => { abTestId: 'abt-123', name: 'Updated', description: 'New desc', - maxDurationDays: 60, roleArn: 'arn:new-role', }); const body = JSON.parse(mockFetch.mock.calls[0]![1].body); expect(body.name).toBe('Updated'); expect(body.description).toBe('New desc'); - expect(body.maxDurationDays).toBe(60); expect(body.roleArn).toBe('arn:new-role'); }); }); diff --git a/src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts b/src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts new file mode 100644 index 000000000..262aad0e1 --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-batch-evaluation.test.ts @@ -0,0 +1,357 @@ +import { + deleteBatchEvaluation, + getBatchEvaluation, + listBatchEvaluations, + startBatchEvaluation, + stopBatchEvaluation, +} from '../agentcore-batch-evaluation.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +vi.mock('../account', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + sessionToken: 'TOKEN', + }), +})); + +vi.mock('@smithy/signature-v4', () => ({ + SignatureV4: class { + // eslint-disable-next-line @typescript-eslint/require-await + async sign(request: { headers: Record }) { + return { headers: { ...request.headers, Authorization: 'signed' } }; + } + }, +})); + +vi.mock('@aws-crypto/sha256-js', () => ({ + Sha256: class {}, +})); + +vi.mock('@aws-sdk/credential-provider-node', () => ({ + defaultProvider: vi.fn(), +})); + +function mockJsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +describe('agentcore-batch-evaluation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('startBatchEvaluation', () => { + it('sends POST to /evaluations/batch-evaluate with correct body', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:batch-evaluation/batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + const result = await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + }); + + expect(result.batchEvaluationId).toBe('batch-123'); + expect(result.name).toBe('MyBatchEval'); + expect(result.status).toBe('PENDING'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/evaluations/batch-evaluate'), + expect.objectContaining({ method: 'POST' }) + ); + + const fetchCall = mockFetch.mock.calls[0]!; + const body = JSON.parse(fetchCall[1].body); + expect(body.batchEvaluationName).toBe('MyBatchEval'); + expect(body.evaluators).toEqual([{ evaluatorId: 'eval-1' }]); + }); + + it('includes kmsKeyArn when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + kmsKeyArn: 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012', + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.kmsKeyArn).toBe('arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'); + }); + + it('omits kmsKeyArn when not provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.kmsKeyArn).toBeUndefined(); + }); + + it('includes description when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + description: 'Test evaluation run', + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.description).toBe('Test evaluation run'); + }); + + it('includes clientToken when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + clientToken: 'token-abc', + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.clientToken).toBe('token-abc'); + }); + + it('includes evaluationMetadata when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'PENDING', + }) + ); + + await startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [{ evaluatorId: 'eval-1' }], + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: ['bedrock-agentcore'], + logGroupNames: ['my-log-group'], + }, + }, + evaluationMetadata: { + sessionMetadata: [{ sessionId: 'sess-1', metadata: { referenceAnswer: 'answer' } }], + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.evaluationMetadata.sessionMetadata).toEqual([ + { sessionId: 'sess-1', metadata: { referenceAnswer: 'answer' } }, + ]); + }); + + it('throws on non-ok response', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + headers: new Map([['x-amzn-requestid', 'test-request-id']]), + text: () => Promise.resolve('Bad Request'), + }); + + await expect( + startBatchEvaluation({ + region: 'us-west-2', + name: 'MyBatchEval', + evaluators: [], + dataSourceConfig: { + cloudWatchLogs: { serviceNames: [], logGroupNames: [] }, + }, + }) + ).rejects.toThrow('BatchEvaluation API error (400)'); + }); + }); + + describe('getBatchEvaluation', () => { + it('sends GET to /evaluations/batch-evaluate/{id}', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'COMPLETED', + kmsKeyArn: 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012', + }) + ); + + const result = await getBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); + + expect(result.batchEvaluationId).toBe('batch-123'); + expect(result.name).toBe('MyBatchEval'); + expect(result.status).toBe('COMPLETED'); + expect(result.kmsKeyArn).toBe('arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/evaluations/batch-evaluate/batch-123'), + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('returns undefined kmsKeyArn when not present in response', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + batchEvaluationArn: 'arn:batch-123', + batchEvaluationName: 'MyBatchEval', + status: 'COMPLETED', + }) + ); + + const result = await getBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); + expect(result.kmsKeyArn).toBeUndefined(); + }); + }); + + describe('listBatchEvaluations', () => { + it('sends GET to /evaluations/batch-evaluate', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluations: [ + { batchEvaluationId: 'b1', name: 'Eval1', status: 'COMPLETED' }, + { batchEvaluationId: 'b2', name: 'Eval2', status: 'PENDING' }, + ], + }) + ); + + const result = await listBatchEvaluations({ region: 'us-west-2' }); + + expect(result.batchEvaluations).toHaveLength(2); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/evaluations/batch-evaluate'), + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('includes maxResults and nextToken query params', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ batchEvaluations: [], nextToken: undefined })); + + await listBatchEvaluations({ region: 'us-west-2', maxResults: 5, nextToken: 'page2' }); + + const url = mockFetch.mock.calls[0]![0] as string; + expect(url).toContain('maxResults=5'); + expect(url).toContain('nextToken=page2'); + }); + }); + + describe('stopBatchEvaluation', () => { + it('sends POST to /evaluations/batch-evaluate/{id}/stop', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + batchEvaluationId: 'batch-123', + status: 'STOPPING', + }) + ); + + const result = await stopBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); + + expect(result.batchEvaluationId).toBe('batch-123'); + expect(result.status).toBe('STOPPING'); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/evaluations/batch-evaluate/batch-123/stop'), + expect.objectContaining({ method: 'POST' }) + ); + }); + }); + + describe('deleteBatchEvaluation', () => { + it('sends DELETE to /evaluations/batch-evaluate/{id}', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 204, + headers: new Map(), + json: () => Promise.resolve({}), + text: () => Promise.resolve(''), + }); + + await deleteBatchEvaluation({ region: 'us-west-2', batchEvaluationId: 'batch-123' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/evaluations/batch-evaluate/batch-123'), + expect.objectContaining({ method: 'DELETE' }) + ); + }); + }); +}); diff --git a/src/cli/aws/__tests__/agentcore-config-bundles.test.ts b/src/cli/aws/__tests__/agentcore-config-bundles.test.ts new file mode 100644 index 000000000..30a776b13 --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-config-bundles.test.ts @@ -0,0 +1,84 @@ +import { listConfigurationBundles } from '../agentcore-config-bundles.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +vi.mock('../account', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + sessionToken: 'TOKEN', + }), +})); + +vi.mock('@smithy/signature-v4', () => ({ + SignatureV4: class { + // eslint-disable-next-line @typescript-eslint/require-await + async sign(request: { headers: Record }) { + return { headers: { ...request.headers, Authorization: 'signed' } }; + } + }, +})); + +vi.mock('@aws-crypto/sha256-js', () => ({ + Sha256: class {}, +})); + +vi.mock('@aws-sdk/credential-provider-node', () => ({ + defaultProvider: vi.fn(), +})); + +function mockJsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +describe('agentcore-config-bundles', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('listConfigurationBundles', () => { + it('returns bundles with createdAt timestamp', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + bundles: [ + { + bundleArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:configuration-bundle/myBundle-abc123', + bundleId: 'myBundle-abc123', + bundleName: 'myBundle', + createdAt: 1780442814.787, + }, + { + bundleArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:configuration-bundle/otherBundle-def456', + bundleId: 'otherBundle-def456', + bundleName: 'otherBundle', + description: 'A test bundle', + createdAt: 1780440000.0, + }, + ], + }) + ); + + const result = await listConfigurationBundles({ region: 'us-west-2' }); + + expect(result.bundles).toHaveLength(2); + expect(result.bundles[0]!.bundleName).toBe('myBundle'); + expect(result.bundles[0]!.createdAt).toBe(1780442814.787); + expect(result.bundles[1]!.createdAt).toBe(1780440000.0); + }); + + it('returns empty array when no bundles exist', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ bundles: [] })); + + const result = await listConfigurationBundles({ region: 'us-west-2' }); + + expect(result.bundles).toEqual([]); + }); + }); +}); diff --git a/src/cli/aws/__tests__/agentcore-harness.test.ts b/src/cli/aws/__tests__/agentcore-harness.test.ts index 7d14d776c..bc7fb314a 100644 --- a/src/cli/aws/__tests__/agentcore-harness.test.ts +++ b/src/cli/aws/__tests__/agentcore-harness.test.ts @@ -1,12 +1,4 @@ -import { - createHarness, - deleteHarness, - getHarness, - invokeHarness, - listAllHarnesses, - listHarnesses, - updateHarness, -} from '../agentcore-harness.js'; +import { deleteHarness, getHarness, invokeHarness } from '../agentcore-harness.js'; import { EventStreamCodec } from '@smithy/eventstream-codec'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -38,56 +30,6 @@ describe('Harness control plane operations', () => { vi.clearAllMocks(); }); - describe('createHarness', () => { - it('sends POST /harnesses with correct body', async () => { - const harness = { harnessId: 'h-123', harnessName: 'test', status: 'CREATING' }; - mockRequest.mockResolvedValue({ harness }); - - const result = await createHarness({ - region: 'us-west-2', - harnessName: 'test', - executionRoleArn: 'arn:aws:iam::123:role/TestRole', - model: { bedrockModelConfig: { modelId: 'us.anthropic.claude-sonnet-4-6-20250514-v1:0' } }, - systemPrompt: [{ text: 'You are helpful.' }], - tools: [{ type: 'agentcore_browser', name: 'browser' }], - maxIterations: 75, - }); - - expect(result.harness.harnessId).toBe('h-123'); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'POST', - path: '/harnesses', - body: expect.objectContaining({ - harnessName: 'test', - executionRoleArn: 'arn:aws:iam::123:role/TestRole', - clientToken: expect.any(String), - model: { bedrockModelConfig: { modelId: 'us.anthropic.claude-sonnet-4-6-20250514-v1:0' } }, - systemPrompt: [{ text: 'You are helpful.' }], - tools: [{ type: 'agentcore_browser', name: 'browser' }], - maxIterations: 75, - }), - }) - ); - }); - - it('omits optional fields when not provided', async () => { - mockRequest.mockResolvedValue({ harness: { harnessId: 'h-1' } }); - - await createHarness({ - region: 'us-west-2', - harnessName: 'minimal', - executionRoleArn: 'arn:aws:iam::123:role/R', - }); - - const body = mockRequest.mock.calls[0]![0].body; - expect(body.model).toBeUndefined(); - expect(body.tools).toBeUndefined(); - expect(body.memory).toBeUndefined(); - expect(body.maxIterations).toBeUndefined(); - }); - }); - describe('getHarness', () => { it('sends GET /harnesses/{harnessId}', async () => { const harness = { harnessId: 'h-123', status: 'READY' }; @@ -105,46 +47,6 @@ describe('Harness control plane operations', () => { }); }); - describe('updateHarness', () => { - it('sends PATCH /harnesses/{harnessId}', async () => { - mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123', status: 'UPDATING' } }); - - await updateHarness({ - region: 'us-west-2', - harnessId: 'h-123', - model: { bedrockModelConfig: { modelId: 'new-model' } }, - maxTokens: 4096, - }); - - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'PATCH', - path: '/harnesses/h-123', - body: expect.objectContaining({ - clientToken: expect.any(String), - model: { bedrockModelConfig: { modelId: 'new-model' } }, - maxTokens: 4096, - }), - }) - ); - }); - - it('passes nullable wrapper fields for memory and environmentArtifact', async () => { - mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123' } }); - - await updateHarness({ - region: 'us-west-2', - harnessId: 'h-123', - memory: { optionalValue: null }, - environmentArtifact: { optionalValue: null }, - }); - - const body = mockRequest.mock.calls[0]![0].body; - expect(body.memory).toEqual({ optionalValue: null }); - expect(body.environmentArtifact).toEqual({ optionalValue: null }); - }); - }); - describe('deleteHarness', () => { it('sends DELETE /harnesses/{harnessId} with clientToken query param', async () => { mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123', status: 'DELETING' } }); @@ -160,47 +62,6 @@ describe('Harness control plane operations', () => { ); }); }); - - describe('listHarnesses', () => { - it('sends GET /harnesses with query params', async () => { - mockRequest.mockResolvedValue({ - harnesses: [{ harnessId: 'h-1', harnessName: 'one' }], - nextToken: undefined, - }); - - const result = await listHarnesses({ region: 'us-west-2', maxResults: 10 }); - - expect(result.harnesses).toHaveLength(1); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'GET', - path: '/harnesses', - query: { maxResults: '10' }, - }) - ); - }); - }); - - describe('listAllHarnesses', () => { - it('auto-paginates across multiple pages', async () => { - mockRequest - .mockResolvedValueOnce({ - harnesses: [{ harnessId: 'h-1' }], - nextToken: 'tok-1', - }) - .mockResolvedValueOnce({ - harnesses: [{ harnessId: 'h-2' }], - nextToken: undefined, - }); - - const all = await listAllHarnesses('us-west-2'); - - expect(all).toHaveLength(2); - expect(all[0]!.harnessId).toBe('h-1'); - expect(all[1]!.harnessId).toBe('h-2'); - expect(mockRequest).toHaveBeenCalledTimes(2); - }); - }); }); describe('invokeHarness (streaming)', () => { diff --git a/src/cli/aws/__tests__/agentcore-http-gateways.test.ts b/src/cli/aws/__tests__/agentcore-http-gateways.test.ts deleted file mode 100644 index f9ace9a7a..000000000 --- a/src/cli/aws/__tests__/agentcore-http-gateways.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { createHttpGatewayTarget, getHttpGateway, listHttpGatewayTargets } from '../agentcore-http-gateways.js'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - -vi.mock('../account', () => ({ - getCredentialProvider: vi.fn().mockReturnValue({ - accessKeyId: 'AKID', - secretAccessKey: 'SECRET', - sessionToken: 'TOKEN', - }), -})); - -vi.mock('@smithy/signature-v4', () => ({ - SignatureV4: class { - // eslint-disable-next-line @typescript-eslint/require-await - async sign(request: { headers: Record }) { - return { headers: { ...request.headers, Authorization: 'signed' } }; - } - }, -})); - -vi.mock('@aws-crypto/sha256-js', () => ({ - Sha256: class {}, -})); - -vi.mock('@aws-sdk/credential-provider-node', () => ({ - defaultProvider: vi.fn(), -})); - -function mockJsonResponse(body: unknown, status = 200) { - return { - ok: status >= 200 && status < 300, - status, - headers: new Map([['x-amzn-requestid', 'test-request-id']]), - json: () => Promise.resolve(body), - text: () => Promise.resolve(JSON.stringify(body)), - }; -} - -describe('agentcore-http-gateways', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('createHttpGatewayTarget', () => { - it('sends agentcoreRuntime in request body', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - targetId: 'tgt-001', - name: 'my-target', - status: 'CREATING', - }) - ); - - const result = await createHttpGatewayTarget({ - region: 'us-east-1', - gatewayId: 'gw-123', - targetName: 'my-target', - runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', - qualifier: 'DEFAULT', - }); - - expect(result.targetId).toBe('tgt-001'); - expect(result.name).toBe('my-target'); - expect(mockFetch).toHaveBeenCalledTimes(1); - - const body = JSON.parse(mockFetch.mock.calls[0]![1].body); - expect(body.name).toBe('my-target'); - expect(body.targetConfiguration.http.agentcoreRuntime).toEqual({ - arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', - qualifier: 'DEFAULT', - }); - expect(body.credentialProviderConfigurations).toEqual([{ credentialProviderType: 'GATEWAY_IAM_ROLE' }]); - expect(body.clientToken).toBeDefined(); - }); - - it('falls back to runtimeTargetConfiguration on ValidationException', async () => { - // First call fails with ValidationException - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - headers: new Map([['x-amzn-requestid', 'test-request-id']]), - text: () => Promise.resolve('ValidationException: Unknown field agentcoreRuntime'), - }); - // Second call (fallback) succeeds - mockFetch.mockResolvedValueOnce( - mockJsonResponse({ - targetId: 'tgt-002', - name: 'my-target', - status: 'CREATING', - }) - ); - - const result = await createHttpGatewayTarget({ - region: 'us-east-1', - gatewayId: 'gw-123', - targetName: 'my-target', - runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', - }); - - expect(result.targetId).toBe('tgt-002'); - expect(mockFetch).toHaveBeenCalledTimes(2); - - // Second call should use runtimeTargetConfiguration - const fallbackBody = JSON.parse(mockFetch.mock.calls[1]![1].body); - expect(fallbackBody.targetConfiguration.http.runtimeTargetConfiguration).toEqual({ - arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', - qualifier: 'DEFAULT', - }); - }); - - it('falls back to runtimeTargetConfiguration on 400 status', async () => { - // First call fails with 400 - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - headers: new Map([['x-amzn-requestid', 'test-request-id']]), - text: () => Promise.resolve('400 Bad Request'), - }); - // Second call (fallback) succeeds - mockFetch.mockResolvedValueOnce( - mockJsonResponse({ - targetId: 'tgt-003', - name: 'my-target', - status: 'CREATING', - }) - ); - - const result = await createHttpGatewayTarget({ - region: 'us-east-1', - gatewayId: 'gw-123', - targetName: 'my-target', - runtimeArn: 'arn:runtime', - }); - - expect(result.targetId).toBe('tgt-003'); - expect(mockFetch).toHaveBeenCalledTimes(2); - }); - - it('throws on non-validation errors (no fallback)', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - headers: new Map([['x-amzn-requestid', 'test-request-id']]), - text: () => Promise.resolve('Internal Server Error'), - }); - - await expect( - createHttpGatewayTarget({ - region: 'us-east-1', - gatewayId: 'gw-123', - targetName: 'my-target', - runtimeArn: 'arn:runtime', - }) - ).rejects.toThrow('Failed to create target'); - - // Only one call — no fallback attempt - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - }); - - describe('getHttpGateway', () => { - it('returns gateway details', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - gatewayId: 'gw-123', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-123', - gatewayUrl: 'https://gw-123.example.com', - name: 'my-gateway', - status: 'READY', - authorizerType: 'AWS_IAM', - roleArn: 'arn:aws:iam::123:role/GwRole', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-02T00:00:00Z', - }) - ); - - const result = await getHttpGateway({ region: 'us-east-1', gatewayId: 'gw-123' }); - - expect(result.gatewayId).toBe('gw-123'); - expect(result.name).toBe('my-gateway'); - expect(result.status).toBe('READY'); - expect(result.gatewayUrl).toBe('https://gw-123.example.com'); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/gateways/gw-123'), - expect.objectContaining({ method: 'GET' }) - ); - }); - }); - - describe('listHttpGatewayTargets', () => { - it('returns targets array', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - targets: [ - { targetId: 'tgt-1', name: 'target-1', status: 'READY' }, - { targetId: 'tgt-2', name: 'target-2', status: 'CREATING' }, - ], - }) - ); - - const result = await listHttpGatewayTargets({ - region: 'us-east-1', - gatewayId: 'gw-123', - }); - - expect(result.targets).toHaveLength(2); - expect(result.targets[0]!.targetId).toBe('tgt-1'); - expect(result.targets[0]!.name).toBe('target-1'); - expect(result.targets[1]!.targetId).toBe('tgt-2'); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/gateways/gw-123/targets'), - expect.objectContaining({ method: 'GET' }) - ); - }); - - it('handles response with items field instead of targets', async () => { - mockFetch.mockResolvedValue( - mockJsonResponse({ - items: [{ targetId: 'tgt-1', name: 'target-1', status: 'READY' }], - }) - ); - - const result = await listHttpGatewayTargets({ - region: 'us-east-1', - gatewayId: 'gw-123', - }); - - expect(result.targets).toHaveLength(1); - expect(result.targets[0]!.targetId).toBe('tgt-1'); - }); - }); -}); diff --git a/src/cli/aws/__tests__/agentcore-recommendation.test.ts b/src/cli/aws/__tests__/agentcore-recommendation.test.ts index 1b330cf30..f27fdb6fb 100644 --- a/src/cli/aws/__tests__/agentcore-recommendation.test.ts +++ b/src/cli/aws/__tests__/agentcore-recommendation.test.ts @@ -171,6 +171,81 @@ describe('agentcore-recommendation', () => { expect(body.description).toBe('Test description'); }); + it('includes kmsKeyArn when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + recommendationId: 'r1', + recommendationArn: 'arn:1', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }) + ); + + await startRecommendation({ + region: 'us-west-2', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + recommendationConfig: { + systemPromptRecommendationConfig: { + systemPrompt: { text: '' }, + agentTraces: { + cloudwatchLogs: { + logGroupArns: [], + serviceNames: ['bedrock-agentcore'], + startTime: '2026-03-23T00:00:00.000Z', + endTime: '2026-03-30T00:00:00.000Z', + }, + }, + evaluationConfig: { + evaluators: [{ evaluatorArn: 'arn:aws:bedrock-agentcore:::evaluator/Builtin.Helpfulness' }], + }, + }, + }, + kmsKeyArn: 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012', + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.kmsKeyArn).toBe('arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'); + }); + + it('omits kmsKeyArn when not provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ + recommendationId: 'r1', + recommendationArn: 'arn:1', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + }) + ); + + await startRecommendation({ + region: 'us-west-2', + name: 'MyRec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + recommendationConfig: { + systemPromptRecommendationConfig: { + systemPrompt: { text: '' }, + agentTraces: { + cloudwatchLogs: { + logGroupArns: [], + serviceNames: ['bedrock-agentcore'], + startTime: '2026-03-23T00:00:00.000Z', + endTime: '2026-03-30T00:00:00.000Z', + }, + }, + evaluationConfig: { + evaluators: [{ evaluatorArn: 'arn:aws:bedrock-agentcore:::evaluator/Builtin.Helpfulness' }], + }, + }, + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0]![1].body); + expect(body.kmsKeyArn).toBeUndefined(); + }); + it('throws on non-ok response', async () => { mockFetch.mockResolvedValue({ ok: false, @@ -215,6 +290,7 @@ describe('agentcore-recommendation', () => { expect(result.recommendationResult?.systemPromptRecommendationResult?.recommendedSystemPrompt).toBe( 'Optimized prompt' ); + expect(result.recommendationResult?.systemPromptRecommendationResult?.explanation).toBe('Made it better'); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('/recommendations/rec-123'), expect.objectContaining({ method: 'GET' }) diff --git a/src/cli/aws/__tests__/agentcore.test.ts b/src/cli/aws/__tests__/agentcore.test.ts index c0b4653ee..d4fd3003b 100644 --- a/src/cli/aws/__tests__/agentcore.test.ts +++ b/src/cli/aws/__tests__/agentcore.test.ts @@ -41,6 +41,24 @@ describe('parseSSELine', () => { expect(result.error).toBeNull(); }); + it('extracts text delta from ConverseStream-shaped events', () => { + const result = parseSSELine('data: {"event": {"contentBlockDelta": {"delta": {"text": "Hello"}}}}'); + expect(result.content).toBe('Hello'); + expect(result.error).toBeNull(); + }); + + it('preserves whitespace-only text deltas', () => { + const result = parseSSELine('data: {"event": {"contentBlockDelta": {"delta": {"text": " "}}}}'); + expect(result.content).toBe(' '); + expect(result.error).toBeNull(); + }); + + it('returns null for ConverseStream events without a text delta', () => { + const result = parseSSELine('data: {"event": {"messageStop": {"stopReason": "end_turn"}}}'); + expect(result.content).toBeNull(); + expect(result.error).toBeNull(); + }); + it('handles empty data field', () => { const result = parseSSELine('data: '); expect(result.content).toBe(''); diff --git a/src/cli/aws/__tests__/bedrock-agent.test.ts b/src/cli/aws/__tests__/bedrock-agent.test.ts new file mode 100644 index 000000000..12344ffb3 --- /dev/null +++ b/src/cli/aws/__tests__/bedrock-agent.test.ts @@ -0,0 +1,262 @@ +import { + getDataSource, + getKnowledgeBase, + getLatestIngestionJob, + listDataSources, + listIngestionJobs, + startIngestionJob, +} from '../bedrock-agent'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSend = vi.fn(); + +vi.mock('@aws-sdk/client-bedrock-agent', () => ({ + BedrockAgentClient: class { + send = mockSend; + }, + GetKnowledgeBaseCommand: class { + constructor(public readonly input: unknown) {} + }, + GetDataSourceCommand: class { + constructor(public readonly input: unknown) {} + }, + ListDataSourcesCommand: class { + constructor(public readonly input: unknown) {} + }, + ListIngestionJobsCommand: class { + constructor(public readonly input: unknown) {} + }, + GetIngestionJobCommand: class { + constructor(public readonly input: unknown) {} + }, + StartIngestionJobCommand: class { + constructor(public readonly input: unknown) {} + }, +})); + +vi.mock('../account', () => ({ + getCredentialProvider: vi.fn().mockReturnValue({}), +})); + +describe('bedrock-agent wrapper', () => { + beforeEach(() => { + mockSend.mockReset(); + }); + + describe('getKnowledgeBase', () => { + it('returns the knowledge base when present', async () => { + mockSend.mockResolvedValueOnce({ + knowledgeBase: { knowledgeBaseId: 'KB123', name: 'docs', status: 'ACTIVE' }, + }); + const result = await getKnowledgeBase({ region: 'us-west-2', knowledgeBaseId: 'KB123' }); + expect(result?.knowledgeBaseId).toBe('KB123'); + expect(result?.status).toBe('ACTIVE'); + }); + + it('returns null when KB not found', async () => { + mockSend.mockRejectedValueOnce(Object.assign(new Error('not found'), { name: 'ResourceNotFoundException' })); + const result = await getKnowledgeBase({ region: 'us-west-2', knowledgeBaseId: 'KBMISSING' }); + expect(result).toBeNull(); + }); + + it('rethrows other errors', async () => { + mockSend.mockRejectedValueOnce(new Error('throttled')); + await expect(getKnowledgeBase({ region: 'us-west-2', knowledgeBaseId: 'KB1' })).rejects.toThrow('throttled'); + }); + }); + + describe('getDataSource', () => { + it('returns the data source when present', async () => { + mockSend.mockResolvedValueOnce({ + dataSource: { dataSourceId: 'DS1', knowledgeBaseId: 'KB1', name: 'ds', status: 'AVAILABLE' }, + }); + const result = await getDataSource({ region: 'us-west-2', knowledgeBaseId: 'KB1', dataSourceId: 'DS1' }); + expect(result?.dataSourceId).toBe('DS1'); + }); + + it('returns null when DS not found', async () => { + mockSend.mockRejectedValueOnce(Object.assign(new Error('not found'), { name: 'ResourceNotFoundException' })); + const result = await getDataSource({ + region: 'us-west-2', + knowledgeBaseId: 'KB1', + dataSourceId: 'MISSING', + }); + expect(result).toBeNull(); + }); + }); + + describe('listIngestionJobs', () => { + it('returns the list', async () => { + mockSend.mockResolvedValueOnce({ + ingestionJobSummaries: [{ ingestionJobId: 'IJ1', status: 'COMPLETE', startedAt: new Date('2026-05-01') }], + }); + const result = await listIngestionJobs({ + region: 'us-west-2', + knowledgeBaseId: 'KB1', + dataSourceId: 'DS1', + }); + expect(result).toHaveLength(1); + expect(result[0]?.ingestionJobId).toBe('IJ1'); + }); + + it('returns empty array when none', async () => { + mockSend.mockResolvedValueOnce({ ingestionJobSummaries: [] }); + const result = await listIngestionJobs({ + region: 'us-west-2', + knowledgeBaseId: 'KB1', + dataSourceId: 'DS1', + }); + expect(result).toEqual([]); + }); + + it('paginates through every page until nextToken is undefined', async () => { + mockSend.mockResolvedValueOnce({ + ingestionJobSummaries: [{ ingestionJobId: 'IJ1', status: 'COMPLETE', startedAt: new Date('2026-05-01') }], + nextToken: 'page2', + }); + mockSend.mockResolvedValueOnce({ + ingestionJobSummaries: [{ ingestionJobId: 'IJ2', status: 'COMPLETE', startedAt: new Date('2026-05-02') }], + nextToken: 'page3', + }); + mockSend.mockResolvedValueOnce({ + ingestionJobSummaries: [{ ingestionJobId: 'IJ3', status: 'IN_PROGRESS', startedAt: new Date('2026-05-03') }], + // no nextToken — last page + }); + + const result = await listIngestionJobs({ + region: 'us-west-2', + knowledgeBaseId: 'KB1', + dataSourceId: 'DS1', + }); + + expect(result).toHaveLength(3); + expect(result.map(s => s.ingestionJobId)).toEqual(['IJ1', 'IJ2', 'IJ3']); + expect(mockSend).toHaveBeenCalledTimes(3); + }); + + it('returns empty array on ResourceNotFoundException', async () => { + mockSend.mockRejectedValueOnce(Object.assign(new Error('not found'), { name: 'ResourceNotFoundException' })); + const result = await listIngestionJobs({ + region: 'us-west-2', + knowledgeBaseId: 'KB1', + dataSourceId: 'DS1', + }); + expect(result).toEqual([]); + }); + }); + + describe('listDataSources', () => { + it('returns the data source summaries', async () => { + mockSend.mockResolvedValueOnce({ + dataSourceSummaries: [ + { dataSourceId: 'DS1', name: 'a', status: 'AVAILABLE' }, + { dataSourceId: 'DS2', name: 'b', status: 'AVAILABLE' }, + ], + }); + const result = await listDataSources({ region: 'us-west-2', knowledgeBaseId: 'KB1' }); + expect(result).toHaveLength(2); + expect(result[0]?.dataSourceId).toBe('DS1'); + }); + + it('returns empty array when KB has no DSes', async () => { + mockSend.mockResolvedValueOnce({ dataSourceSummaries: [] }); + const result = await listDataSources({ region: 'us-west-2', knowledgeBaseId: 'KB1' }); + expect(result).toEqual([]); + }); + + it('returns empty array on ResourceNotFoundException', async () => { + mockSend.mockRejectedValueOnce(Object.assign(new Error('not found'), { name: 'ResourceNotFoundException' })); + const result = await listDataSources({ region: 'us-west-2', knowledgeBaseId: 'KB-MISSING' }); + expect(result).toEqual([]); + }); + + it('paginates through every page until nextToken is undefined', async () => { + mockSend.mockResolvedValueOnce({ + dataSourceSummaries: [{ dataSourceId: 'DS1', name: 'a', status: 'AVAILABLE' }], + nextToken: 'page2', + }); + mockSend.mockResolvedValueOnce({ + dataSourceSummaries: [{ dataSourceId: 'DS2', name: 'b', status: 'AVAILABLE' }], + nextToken: 'page3', + }); + mockSend.mockResolvedValueOnce({ + dataSourceSummaries: [{ dataSourceId: 'DS3', name: 'c', status: 'AVAILABLE' }], + }); + + const result = await listDataSources({ region: 'us-west-2', knowledgeBaseId: 'KB1' }); + + expect(result.map(s => s.dataSourceId)).toEqual(['DS1', 'DS2', 'DS3']); + expect(mockSend).toHaveBeenCalledTimes(3); + }); + }); + + describe('startIngestionJob', () => { + it('returns the ingestion job on success', async () => { + mockSend.mockResolvedValueOnce({ + ingestionJob: { ingestionJobId: 'IJ-NEW', status: 'STARTING' }, + }); + const result = await startIngestionJob({ + region: 'us-west-2', + knowledgeBaseId: 'KB1', + dataSourceId: 'DS1', + }); + expect(result.ingestionJobId).toBe('IJ-NEW'); + expect(result.status).toBe('STARTING'); + }); + + it('throws when KB not found', async () => { + mockSend.mockRejectedValueOnce(Object.assign(new Error('not found'), { name: 'ResourceNotFoundException' })); + await expect( + startIngestionJob({ region: 'us-west-2', knowledgeBaseId: 'KB-MISSING', dataSourceId: 'DS1' }) + ).rejects.toThrow(); + }); + + it('throws on validation errors verbatim', async () => { + mockSend.mockRejectedValueOnce(new Error('No documents to ingest')); + await expect( + startIngestionJob({ region: 'us-west-2', knowledgeBaseId: 'KB1', dataSourceId: 'DS1' }) + ).rejects.toThrow('No documents to ingest'); + }); + + it('throws when response has no ingestionJob', async () => { + mockSend.mockResolvedValueOnce({}); + await expect( + startIngestionJob({ region: 'us-west-2', knowledgeBaseId: 'KB1', dataSourceId: 'DS1' }) + ).rejects.toThrow(/no ingestion job/i); + }); + }); + + describe('getLatestIngestionJob', () => { + it('returns the most recently started job', async () => { + mockSend.mockResolvedValueOnce({ + ingestionJobSummaries: [ + { ingestionJobId: 'old', status: 'COMPLETE', startedAt: new Date('2026-01-01') }, + { ingestionJobId: 'new', status: 'IN_PROGRESS', startedAt: new Date('2026-05-01') }, + ], + }); + mockSend.mockResolvedValueOnce({ + ingestionJob: { + ingestionJobId: 'new', + status: 'IN_PROGRESS', + statistics: { numberOfDocumentsScanned: 10 }, + }, + }); + const result = await getLatestIngestionJob({ + region: 'us-west-2', + knowledgeBaseId: 'KB1', + dataSourceId: 'DS1', + }); + expect(result?.ingestionJobId).toBe('new'); + }); + + it('returns null when no jobs', async () => { + mockSend.mockResolvedValueOnce({ ingestionJobSummaries: [] }); + const result = await getLatestIngestionJob({ + region: 'us-west-2', + knowledgeBaseId: 'KB1', + dataSourceId: 'DS1', + }); + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/cli/aws/agentcore-ab-tests.ts b/src/cli/aws/agentcore-ab-tests.ts index 12e5a9f83..d14a750b9 100644 --- a/src/cli/aws/agentcore-ab-tests.ts +++ b/src/cli/aws/agentcore-ab-tests.ts @@ -4,6 +4,7 @@ * Uses the AgentCore Evaluation DataPlane API (bedrock-agentcore) * with direct HTTP requests and SigV4 signing. */ +import { JobNotFoundError } from '../../lib'; import { getCredentialProvider } from './account'; import { dataPlaneEndpoint } from './stage-endpoint'; import { Sha256 } from '@aws-crypto/sha256-js'; @@ -93,8 +94,6 @@ export interface CreateABTestOptions { variants: ABTestVariant[]; evaluationConfig: ABTestEvaluationConfig; gatewayFilter?: GatewayFilter; - trafficAllocationConfig?: TrafficAllocationConfig; - maxDurationDays?: number; enableOnCreate?: boolean; } @@ -130,6 +129,7 @@ export interface GetABTestResult { currentRunId?: string; stopReason?: string; failureReason?: string; + errorDetails?: string[]; startedAt?: string; stoppedAt?: string; maxDurationExpiresAt?: string; @@ -146,9 +146,7 @@ export interface UpdateABTestOptions { name?: string; description?: string; variants?: ABTestVariant[]; - trafficAllocationConfig?: TrafficAllocationConfig; evaluationConfig?: ABTestEvaluationConfig; - maxDurationDays?: number; executionStatus?: 'PAUSED' | 'RUNNING' | 'STOPPED'; roleArn?: string; } @@ -247,7 +245,11 @@ async function signedRequestToEndpoint( if (!response.ok) { const errorBody = await response.text(); - throw new Error(`ABTest API error (${response.status}): ${errorBody}`); + const message = `ABTest API error (${response.status}): ${errorBody}`; + if (response.status === 404) { + throw new JobNotFoundError(message, { errorSource: 'service' }); + } + throw new Error(message); } if (response.status === 204) return {}; @@ -273,8 +275,6 @@ export async function createABTest(options: CreateABTestOptions): Promise { const body: Record = { batchEvaluationName: options.name, - evaluators: options.evaluators, dataSourceConfig: options.dataSourceConfig, }; + if (options.evaluators && options.evaluators.length > 0) { + body.evaluators = options.evaluators; + } + if (options.insights && options.insights.length > 0) { + body.insights = options.insights; + } if (options.evaluationMetadata) { body.evaluationMetadata = options.evaluationMetadata; } @@ -288,6 +329,9 @@ export async function startBatchEvaluation(options: StartBatchEvaluationOptions) if (options.clientToken) { body.clientToken = options.clientToken; } + if (options.kmsKeyArn) { + body.kmsKeyArn = options.kmsKeyArn; + } const { data } = await signedRequest({ region: options.region, @@ -328,8 +372,10 @@ export async function getBatchEvaluation(options: GetBatchEvaluationOptions): Pr dataSourceConfig: raw.dataSourceConfig as DataSourceConfig | undefined, outputConfig: raw.outputConfig as OutputConfig | undefined, evaluationResults: raw.evaluationResults as EvaluationResults | undefined, + failureAnalysisResult: raw.failureAnalysisResult as FailureAnalysisResult | undefined, errorDetails: raw.errorDetails as string[] | undefined, description: raw.description as string | undefined, + kmsKeyArn: raw.kmsKeyArn as string | undefined, }; } diff --git a/src/cli/aws/agentcore-config-bundles.ts b/src/cli/aws/agentcore-config-bundles.ts index 1c8ab5143..e2941acfe 100644 --- a/src/cli/aws/agentcore-config-bundles.ts +++ b/src/cli/aws/agentcore-config-bundles.ts @@ -114,6 +114,7 @@ export interface ConfigurationBundleSummary { bundleId: string; bundleName: string; description?: string; + createdAt?: number; } export interface ListConfigurationBundlesResult { diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index f8d548b3e..fd8f80052 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -1,5 +1,6 @@ import type { EvaluationLevel } from '../../schema/schemas/primitives/evaluator'; import { getCredentialProvider } from './account'; +import { controlPlaneEndpoint } from './stage-endpoint'; import { BedrockAgentCoreControlClient, GetAgentRuntimeCommand, @@ -20,13 +21,17 @@ import { /** * Create a shared BedrockAgentCoreControlClient for the given region. + * Respects AGENTCORE_STAGE env var for pre-release endpoint override. * Callers should create one client and reuse it across related operations * to benefit from connection pooling and credential caching. */ export function createControlClient(region: string): BedrockAgentCoreControlClient { + const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + const endpointOverride = stage === 'beta' || stage === 'gamma' ? controlPlaneEndpoint(region) : undefined; return new BedrockAgentCoreControlClient({ region, credentials: getCredentialProvider(), + ...(endpointOverride ? { endpoint: endpointOverride } : {}), }); } @@ -842,6 +847,7 @@ export interface GatewayDetail { }[]; }; }; + protocolType?: string; protocolConfiguration?: { mcp?: { searchType?: string }; }; @@ -948,6 +954,9 @@ export async function getGatewayDetail(options: { region: string; gatewayId: str const tags = await fetchTags(client, response.gatewayArn, 'gateway'); + // Service returns protocolType 'MCP' or null. Null = non-MCP gateway. + const protocolType = response.protocolType === 'MCP' ? 'MCP' : 'None'; + return { gatewayId: response.gatewayId ?? '', gatewayArn: response.gatewayArn ?? '', @@ -957,13 +966,14 @@ export async function getGatewayDetail(options: { region: string; gatewayId: str description: response.description, authorizerType: response.authorizerType ?? 'NONE', roleArn: response.roleArn, - authorizerConfiguration, - protocolConfiguration, + authorizerConfiguration: authorizerConfiguration, + protocolType: protocolType, + protocolConfiguration: protocolConfiguration, exceptionLevel: response.exceptionLevel, policyEngineConfiguration: response.policyEngineConfiguration ? { arn: response.policyEngineConfiguration.arn ?? '', mode: response.policyEngineConfiguration.mode ?? '' } : undefined, - tags, + tags: tags, }; } @@ -999,6 +1009,10 @@ export interface GatewayTargetDetail { smithyModel?: { s3?: { uri: string; bucketOwnerAccountId?: string }; inlinePayload?: string }; lambda?: { lambdaArn: string; toolSchema?: any }; }; + http?: { + agentcoreRuntime?: { runtimeArn: string; qualifier?: string }; + runtimeTargetConfiguration?: { runtimeArn: string; qualifier?: string }; + }; }; credentialProviderConfigurations?: { credentialProviderType: string; @@ -1133,6 +1147,24 @@ export async function getGatewayTargetDetail(options: { } } + if (response.targetConfiguration && 'http' in response.targetConfiguration) { + const http = (response.targetConfiguration as any).http; + targetConfiguration ??= {}; + targetConfiguration.http = {}; + if (http?.agentcoreRuntime) { + targetConfiguration.http.agentcoreRuntime = { + runtimeArn: http.agentcoreRuntime.arn ?? http.agentcoreRuntime.runtimeArn ?? '', + qualifier: http.agentcoreRuntime.qualifier, + }; + } + if (http?.runtimeTargetConfiguration) { + targetConfiguration.http.runtimeTargetConfiguration = { + runtimeArn: http.runtimeTargetConfiguration.arn ?? http.runtimeTargetConfiguration.runtimeArn ?? '', + qualifier: http.runtimeTargetConfiguration.qualifier, + }; + } + } + const credentialProviderConfigurations: GatewayTargetDetail['credentialProviderConfigurations'] = ( response.credentialProviderConfigurations ?? [] ).map((c: any) => ({ diff --git a/src/cli/aws/agentcore-harness.ts b/src/cli/aws/agentcore-harness.ts index 55f321f43..dd7c0e281 100644 --- a/src/cli/aws/agentcore-harness.ts +++ b/src/cli/aws/agentcore-harness.ts @@ -43,10 +43,23 @@ export interface GeminiModelConfig { maxTokens?: number; } +export interface LiteLlmModelConfig { + modelId: string; + apiKeyArn?: string; + /** Base URL for the third-party model provider's API endpoint. */ + apiBase?: string; + temperature?: number; + topP?: number; + maxTokens?: number; + /** Provider-specific parameters passed through to the model provider unchanged. */ + additionalParams?: Record; +} + export interface HarnessModelConfiguration { bedrockModelConfig?: BedrockModelConfig; openAiModelConfig?: OpenAiModelConfig; geminiModelConfig?: GeminiModelConfig; + liteLlmModelConfig?: LiteLlmModelConfig; } export type HarnessSystemPrompt = { text: string }[]; @@ -59,9 +72,10 @@ export interface HarnessTool { config?: Record; } -export interface HarnessSkill { - path: string; -} +export type HarnessSkill = + | { path: string } + | { s3Uri: string } + | { gitUrl: string; path?: string; auth?: { credentialArn: string; username?: string } }; export interface HarnessAgentCoreMemoryConfiguration { arn: string; @@ -121,74 +135,6 @@ export interface Harness { updatedAt: string; } -export interface HarnessSummary { - harnessId: string; - harnessName: string; - arn: string; - status: HarnessStatus; - createdAt: string; - updatedAt: string; -} - -// ============================================================================ -// CreateHarness -// ============================================================================ - -export interface CreateHarnessOptions { - region: string; - harnessName: string; - executionRoleArn: string; - environment?: HarnessEnvironmentProvider; - environmentArtifact?: HarnessEnvironmentArtifact; - environmentVariables?: Record; - authorizerConfiguration?: Record; - model?: HarnessModelConfiguration; - systemPrompt?: HarnessSystemPrompt; - tools?: HarnessTool[]; - skills?: HarnessSkill[]; - allowedTools?: string[]; - memory?: HarnessMemoryConfiguration; - truncation?: HarnessTruncationConfiguration; - maxIterations?: number; - maxTokens?: number; - timeoutSeconds?: number; - tags?: Record; -} - -export interface CreateHarnessResult { - harness: Harness; -} - -export async function createHarness(options: CreateHarnessOptions): Promise { - const { region, ...rest } = options; - const client = new AgentCoreApiClient({ region, plane: 'control' }); - - const body: Record = { - harnessName: rest.harnessName, - clientToken: randomUUID(), - executionRoleArn: rest.executionRoleArn, - }; - - if (rest.environment) body.environment = rest.environment; - if (rest.environmentArtifact) body.environmentArtifact = rest.environmentArtifact; - if (rest.environmentVariables) body.environmentVariables = rest.environmentVariables; - if (rest.authorizerConfiguration) body.authorizerConfiguration = rest.authorizerConfiguration; - if (rest.model) body.model = rest.model; - if (rest.systemPrompt) body.systemPrompt = rest.systemPrompt; - if (rest.tools) body.tools = rest.tools; - if (rest.skills) body.skills = rest.skills; - if (rest.allowedTools) body.allowedTools = rest.allowedTools; - if (rest.memory) body.memory = rest.memory; - if (rest.truncation) body.truncation = rest.truncation; - if (rest.maxIterations != null) body.maxIterations = rest.maxIterations; - if (rest.maxTokens != null) body.maxTokens = rest.maxTokens; - if (rest.timeoutSeconds != null) body.timeoutSeconds = rest.timeoutSeconds; - if (rest.tags) body.tags = rest.tags; - - const result = await client.request({ method: 'POST', path: '/harnesses', body }); - return result as CreateHarnessResult; -} - // ============================================================================ // GetHarness // ============================================================================ @@ -208,64 +154,6 @@ export async function getHarness(options: GetHarnessOptions): Promise; - authorizerConfiguration?: { optionalValue: Record | null }; - model?: HarnessModelConfiguration; - systemPrompt?: HarnessSystemPrompt; - tools?: HarnessTool[]; - skills?: HarnessSkill[]; - allowedTools?: string[]; - memory?: { optionalValue: HarnessMemoryConfiguration | null }; - truncation?: HarnessTruncationConfiguration; - maxIterations?: number; - maxTokens?: number; - timeoutSeconds?: number; - tags?: Record; -} - -export interface UpdateHarnessResult { - harness: Harness; -} - -export async function updateHarness(options: UpdateHarnessOptions): Promise { - const { region, harnessId, ...rest } = options; - const client = new AgentCoreApiClient({ region, plane: 'control' }); - - const body: Record = { - clientToken: randomUUID(), - }; - - if (rest.executionRoleArn) body.executionRoleArn = rest.executionRoleArn; - if (rest.environment) body.environment = rest.environment; - if (rest.environmentArtifact !== undefined) body.environmentArtifact = rest.environmentArtifact; - if (rest.environmentVariables) body.environmentVariables = rest.environmentVariables; - if (rest.authorizerConfiguration !== undefined) body.authorizerConfiguration = rest.authorizerConfiguration; - if (rest.model) body.model = rest.model; - if (rest.systemPrompt) body.systemPrompt = rest.systemPrompt; - if (rest.tools) body.tools = rest.tools; - if (rest.skills) body.skills = rest.skills; - if (rest.allowedTools) body.allowedTools = rest.allowedTools; - if (rest.memory !== undefined) body.memory = rest.memory; - if (rest.truncation) body.truncation = rest.truncation; - if (rest.maxIterations != null) body.maxIterations = rest.maxIterations; - if (rest.maxTokens != null) body.maxTokens = rest.maxTokens; - if (rest.timeoutSeconds != null) body.timeoutSeconds = rest.timeoutSeconds; - if (rest.tags) body.tags = rest.tags; - - const result = await client.request({ method: 'PATCH', path: `/harnesses/${harnessId}`, body }); - return result as UpdateHarnessResult; -} - // ============================================================================ // DeleteHarness // ============================================================================ @@ -289,42 +177,14 @@ export async function deleteHarness(options: DeleteHarnessOptions): Promise { - const client = new AgentCoreApiClient({ region: options.region, plane: 'control' }); - const query: Record = {}; - if (options.maxResults != null) query.maxResults = String(options.maxResults); - if (options.nextToken) query.nextToken = options.nextToken; - - const result = await client.request({ method: 'GET', path: '/harnesses', query }); - return result as ListHarnessesResult; -} - -export async function listAllHarnesses(region: string): Promise { - const all: HarnessSummary[] = []; - let nextToken: string | undefined; - - do { - const result = await listHarnesses({ region, maxResults: 100, nextToken }); - all.push(...result.harnesses); - nextToken = result.nextToken; - } while (nextToken); - - return all; +/** + * True when a DeleteHarness error means the harness is already gone (HTTP 404) — which callers + * treat as success for cleanup. Keyed on the typed control-plane status code, NOT a substring of + * the message, so unrelated "...does not exist" errors (e.g. a dependent IAM role) are NOT + * misclassified as a missing harness. Shared by teardown and the orphan-remove path. + */ +export function isHarnessNotFoundError(err: unknown): boolean { + return err instanceof AgentCoreApiError && err.statusCode === 404; } // ============================================================================ diff --git a/src/cli/aws/agentcore-http-gateways.ts b/src/cli/aws/agentcore-http-gateways.ts deleted file mode 100644 index 22e45b588..000000000 --- a/src/cli/aws/agentcore-http-gateways.ts +++ /dev/null @@ -1,512 +0,0 @@ -/** - * AWS client wrappers for HTTP Gateway control plane operations. - * - * HTTP gateways are required for A/B testing because MCP gateways - * don't emit spans for treatment propagation. These wrappers use - * direct HTTP requests with SigV4 signing against the control plane. - */ -import { getCredentialProvider } from './account'; -import { controlPlaneEndpoint } from './stage-endpoint'; -import { Sha256 } from '@aws-crypto/sha256-js'; -import { defaultProvider } from '@aws-sdk/credential-provider-node'; -import { HttpRequest } from '@smithy/protocol-http'; -import { SignatureV4 } from '@smithy/signature-v4'; -import { randomUUID } from 'node:crypto'; - -// ============================================================================ -// Types -// ============================================================================ - -// ── Create Gateway ───────────────────────────────────────────────────────── - -export interface CreateHttpGatewayOptions { - region: string; - name: string; - roleArn: string; -} - -export interface CreateHttpGatewayResult { - gatewayId: string; - gatewayArn: string; - name: string; - status: string; -} - -// ── Create Gateway Target ────────────────────────────────────────────────── - -export interface CreateHttpGatewayTargetOptions { - region: string; - gatewayId: string; - targetName: string; - runtimeArn: string; - qualifier?: string; -} - -export interface CreateHttpGatewayTargetResult { - targetId: string; - name: string; - status: string; -} - -// ── Get Gateway ──────────────────────────────────────────────────────────── - -export interface GetHttpGatewayOptions { - region: string; - gatewayId: string; -} - -export interface GetHttpGatewayResult { - gatewayId: string; - gatewayArn: string; - gatewayUrl?: string; - name: string; - status: string; - authorizerType?: string; - roleArn?: string; - createdAt?: string; - updatedAt?: string; -} - -// ── Get Gateway Target ───────────────────────────────────────────────────── - -export interface GetHttpGatewayTargetOptions { - region: string; - gatewayId: string; - targetId: string; -} - -export interface GetHttpGatewayTargetResult { - targetId: string; - name: string; - status: string; - targetConfiguration?: unknown; - createdAt?: string; - updatedAt?: string; -} - -// ── List Gateways ────────────────────────────────────────────────────────── - -export interface ListHttpGatewaysOptions { - region: string; - maxResults?: number; - nextToken?: string; -} - -export interface HttpGatewaySummary { - gatewayId: string; - gatewayArn: string; - name: string; - status: string; -} - -export interface ListHttpGatewaysResult { - gateways: HttpGatewaySummary[]; - nextToken?: string; -} - -// ── List Gateway Targets ────────────────────────────────────────────────── - -export interface ListHttpGatewayTargetsOptions { - region: string; - gatewayId: string; - maxResults?: number; -} - -export interface HttpGatewayTargetSummary { - targetId: string; - name: string; - status: string; -} - -export interface ListHttpGatewayTargetsResult { - targets: HttpGatewayTargetSummary[]; -} - -// ── Delete Gateway Target ────────────────────────────────────────────────── - -export interface DeleteHttpGatewayTargetOptions { - region: string; - gatewayId: string; - targetId: string; -} - -// ── Delete Gateway ───────────────────────────────────────────────────────── - -export interface DeleteHttpGatewayOptions { - region: string; - gatewayId: string; -} - -// ── Wait for Target Ready ────────────────────────────────────────────────── - -export interface WaitForTargetReadyOptions { - region: string; - gatewayId: string; - targetId: string; - /** Maximum time to wait in milliseconds. Defaults to 120000 (120s). */ - timeoutMs?: number; -} - -// ============================================================================ -// HTTP signing helper -// ============================================================================ - -async function signedRequest(options: { - region: string; - method: string; - path: string; - body?: string; -}): Promise { - const { region, method, path, body } = options; - const endpoint = controlPlaneEndpoint(region); - const url = new URL(path, endpoint); - - const query: Record = {}; - url.searchParams.forEach((value, key) => { - query[key] = value; - }); - - const request = new HttpRequest({ - method, - protocol: 'https:', - hostname: url.hostname, - path: url.pathname, - ...(Object.keys(query).length > 0 && { query }), - headers: { - 'Content-Type': 'application/json', - host: url.hostname, - }, - ...(body && { body }), - }); - - const credentials = getCredentialProvider() ?? defaultProvider(); - const service = 'bedrock-agentcore'; - const signer = new SignatureV4({ - service, - region, - credentials, - sha256: Sha256, - }); - - const signedReq = await signer.sign(request); - - const response = await fetch(`${endpoint}${path}`, { - method, - headers: signedReq.headers as Record, - ...(body && { body }), - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`HttpGateway API error (${response.status}): ${errorBody}`); - } - - if (response.status === 204) return {}; - return response.json(); -} - -// ============================================================================ -// Control Plane Operations -// ============================================================================ - -export async function createHttpGateway(options: CreateHttpGatewayOptions): Promise { - const body = JSON.stringify({ - name: options.name, - authorizerType: 'AWS_IAM', - roleArn: options.roleArn, - clientToken: randomUUID(), - }); - - try { - return (await signedRequest({ - region: options.region, - method: 'POST', - path: '/gateways', - body, - })) as CreateHttpGatewayResult; - } catch (err) { - throw new Error( - `Failed to create HTTP gateway "${options.name}": ${err instanceof Error ? err.message : String(err)}` - ); - } -} - -export async function createHttpGatewayTarget( - options: CreateHttpGatewayTargetOptions -): Promise { - const body = JSON.stringify({ - name: options.targetName, - clientToken: randomUUID(), - targetConfiguration: { - http: { - agentcoreRuntime: { - arn: options.runtimeArn, - qualifier: options.qualifier ?? 'DEFAULT', - }, - }, - }, - credentialProviderConfigurations: [{ credentialProviderType: 'GATEWAY_IAM_ROLE' }], - }); - - try { - return (await signedRequest({ - region: options.region, - method: 'POST', - path: `/gateways/${options.gatewayId}/targets`, - body, - })) as CreateHttpGatewayTargetResult; - } catch (err) { - // Fallback: retry with legacy field name if the new name is not yet supported - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('ValidationException') || msg.includes('400')) { - const legacyBody = JSON.stringify({ - name: options.targetName, - clientToken: randomUUID(), - targetConfiguration: { - http: { - runtimeTargetConfiguration: { - arn: options.runtimeArn, - qualifier: options.qualifier ?? 'DEFAULT', - }, - }, - }, - credentialProviderConfigurations: [{ credentialProviderType: 'GATEWAY_IAM_ROLE' }], - }); - try { - return (await signedRequest({ - region: options.region, - method: 'POST', - path: `/gateways/${options.gatewayId}/targets`, - body: legacyBody, - })) as CreateHttpGatewayTargetResult; - } catch { - // Fall through to original error - } - } - throw new Error(`Failed to create target "${options.targetName}" in gateway ${options.gatewayId}: ${msg}`); - } -} - -export async function getHttpGateway(options: GetHttpGatewayOptions): Promise { - const data = await signedRequest({ - region: options.region, - method: 'GET', - path: `/gateways/${options.gatewayId}`, - }); - - return data as GetHttpGatewayResult; -} - -export async function getHttpGatewayTarget(options: GetHttpGatewayTargetOptions): Promise { - const data = await signedRequest({ - region: options.region, - method: 'GET', - path: `/gateways/${options.gatewayId}/targets/${options.targetId}`, - }); - - return data as GetHttpGatewayTargetResult; -} - -export async function listHttpGateways(options: ListHttpGatewaysOptions): Promise { - const params = new URLSearchParams(); - if (options.maxResults) params.set('maxResults', String(options.maxResults)); - if (options.nextToken) params.set('nextToken', options.nextToken); - const query = params.toString(); - - const data = await signedRequest({ - region: options.region, - method: 'GET', - path: `/gateways${query ? `?${query}` : ''}`, - }); - - const result = data as ListHttpGatewaysResult; - return { - gateways: result.gateways ?? [], - nextToken: result.nextToken, - }; -} - -/** - * List all HTTP gateways, paginating through all results. - */ -export async function listAllHttpGateways(options: { region: string }): Promise { - const all: HttpGatewaySummary[] = []; - let nextToken: string | undefined; - - do { - const result = await listHttpGateways({ region: options.region, maxResults: 100, nextToken }); - all.push(...result.gateways); - nextToken = result.nextToken; - } while (nextToken); - - return all; -} - -export async function listHttpGatewayTargets( - options: ListHttpGatewayTargetsOptions -): Promise { - const params = new URLSearchParams(); - if (options.maxResults) params.set('maxResults', String(options.maxResults)); - const query = params.toString(); - - const data = await signedRequest({ - region: options.region, - method: 'GET', - path: `/gateways/${options.gatewayId}/targets${query ? `?${query}` : ''}`, - }); - - const result = data as Record; - return { - targets: (result.items ?? result.targets ?? []) as HttpGatewayTargetSummary[], - }; -} - -export async function deleteHttpGatewayTarget( - options: DeleteHttpGatewayTargetOptions -): Promise<{ success: boolean; error?: string }> { - try { - await signedRequest({ - region: options.region, - method: 'DELETE', - path: `/gateways/${options.gatewayId}/targets/${options.targetId}`, - }); - - // Wait for target to be fully deleted before returning. - // Gateway deletion fails if targets still exist in DELETING state. - const timeoutMs = 60_000; - const startTime = Date.now(); - let delayMs = 2_000; - - while (Date.now() - startTime < timeoutMs) { - try { - await getHttpGatewayTarget({ - region: options.region, - gatewayId: options.gatewayId, - targetId: options.targetId, - }); - // Target still exists — keep waiting - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('(404)') || msg.includes('not found')) { - return { success: true }; // Target confirmed deleted - } - // Transient error — keep polling - } - - const remaining = timeoutMs - (Date.now() - startTime); - if (remaining <= 0) break; - await new Promise(resolve => setTimeout(resolve, Math.min(delayMs, remaining))); - delayMs = Math.min(delayMs * 2, 8_000); - } - - // Polling timed out — target may still be deleting - return { success: false, error: `Timed out waiting for target ${options.targetId} to be fully deleted` }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } -} - -export async function deleteHttpGateway( - options: DeleteHttpGatewayOptions -): Promise<{ success: boolean; error?: string }> { - try { - await signedRequest({ - region: options.region, - method: 'DELETE', - path: `/gateways/${options.gatewayId}`, - }); - return { success: true }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } -} - -/** Terminal states that indicate a resource will never become READY. */ -const TERMINAL_FAILURE_STATES = ['FAILED', 'CREATE_FAILED', 'UPDATE_FAILED', 'DELETING', 'DELETED'] as const; - -export async function waitForGatewayReady(options: { - region: string; - gatewayId: string; - timeoutMs?: number; -}): Promise { - const timeoutMs = options.timeoutMs ?? 120_000; - const startTime = Date.now(); - let delayMs = 2_000; - - while (Date.now() - startTime < timeoutMs) { - const gateway = await getHttpGateway({ - region: options.region, - gatewayId: options.gatewayId, - }); - - if (gateway.status === 'READY') return gateway; - - if ((TERMINAL_FAILURE_STATES as readonly string[]).includes(gateway.status)) { - throw new Error( - `Gateway ${options.gatewayId} reached terminal state '${gateway.status}' and will not become READY` - ); - } - - const remaining = timeoutMs - (Date.now() - startTime); - if (remaining <= 0) break; - - await new Promise(resolve => setTimeout(resolve, Math.min(delayMs, remaining))); - delayMs = Math.min(delayMs * 2, 16_000); - } - - throw new Error( - `Timed out waiting for gateway ${options.gatewayId} to become READY after ${Math.round(timeoutMs / 1000)}s` - ); -} - -export async function waitForTargetReady(options: WaitForTargetReadyOptions): Promise { - const timeoutMs = options.timeoutMs ?? 120_000; - const startTime = Date.now(); - let delayMs = 2_000; - - while (Date.now() - startTime < timeoutMs) { - let target; - try { - target = await getHttpGatewayTarget({ - region: options.region, - gatewayId: options.gatewayId, - targetId: options.targetId, - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('(404)')) { - throw new Error( - `Target ${options.targetId} not found during readiness poll — it may have been deleted externally` - ); - } - // Retry on transient server errors - if (/\(5\d\d\)/.test(msg)) { - // Continue polling — transient error - const remaining = timeoutMs - (Date.now() - startTime); - if (remaining <= 0) break; - await new Promise(resolve => setTimeout(resolve, delayMs)); - delayMs = Math.min(delayMs * 2, 16_000); - continue; - } - throw err; - } - - if (target.status === 'READY') return target; - - if ((TERMINAL_FAILURE_STATES as readonly string[]).includes(target.status)) { - throw new Error( - `Target ${options.targetId} in gateway ${options.gatewayId} reached terminal state '${target.status}' and will not become READY` - ); - } - - const remaining = timeoutMs - (Date.now() - startTime); - if (remaining <= 0) break; - - await new Promise(resolve => setTimeout(resolve, Math.min(delayMs, remaining))); - delayMs = Math.min(delayMs * 2, 16_000); - } - - throw new Error( - `Timed out waiting for target ${options.targetId} to become READY after ${Math.round(timeoutMs / 1000)}s` - ); -} diff --git a/src/cli/aws/agentcore-payments.ts b/src/cli/aws/agentcore-payments.ts index 42a07c343..bbd9d6272 100644 --- a/src/cli/aws/agentcore-payments.ts +++ b/src/cli/aws/agentcore-payments.ts @@ -5,7 +5,7 @@ * because the Payment APIs are not yet in the SDK client. */ import { getCredentialProvider } from './account'; -import { serviceEndpoint } from './partition'; +import { controlPlaneEndpoint, dataPlaneEndpoint } from './stage-endpoint'; import { Sha256 } from '@aws-crypto/sha256-js'; import { defaultProvider } from '@aws-sdk/credential-provider-node'; import { HttpRequest } from '@smithy/protocol-http'; @@ -82,13 +82,6 @@ interface PaymentManagerDetail { // HTTP signing helper // ============================================================================ -function getControlPlaneEndpoint(region: string): string { - const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); - if (stage === 'beta') return `https://beta.${region}.elcapcp.genesis-primitives.aws.dev`; - if (stage === 'gamma') return `https://gamma.${region}.elcapcp.genesis-primitives.aws.dev`; - return `https://${serviceEndpoint('bedrock-agentcore-control', region)}`; -} - async function signedRequest(options: { region: string; method: string; @@ -96,7 +89,7 @@ async function signedRequest(options: { body?: string; }): Promise { const { region, method, path, body } = options; - const endpoint = getControlPlaneEndpoint(region); + const endpoint = controlPlaneEndpoint(region); const url = new URL(path, endpoint); const query: Record = {}; @@ -317,13 +310,6 @@ export async function getPaymentManager(options: GetPaymentManagerOptions): Prom // Data Plane Operations (Payment Sessions) // ============================================================================ -function getDataPlaneEndpoint(region: string): string { - const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); - if (stage === 'beta') return `https://beta.${region}.elcapdp.genesis-primitives.aws.dev`; - if (stage === 'gamma') return `https://gamma.${region}.elcapdp.genesis-primitives.aws.dev`; - return `https://${serviceEndpoint('bedrock-agentcore', region)}`; -} - async function signedDataPlaneRequest(options: { region: string; method: string; @@ -332,7 +318,7 @@ async function signedDataPlaneRequest(options: { extraHeaders?: Record; }): Promise { const { region, method, path, body, extraHeaders } = options; - const endpoint = getDataPlaneEndpoint(region); + const endpoint = dataPlaneEndpoint(region); const url = new URL(path, endpoint); const query: Record = {}; diff --git a/src/cli/aws/agentcore-recommendation.ts b/src/cli/aws/agentcore-recommendation.ts index 88ae0473a..2cdbea000 100644 --- a/src/cli/aws/agentcore-recommendation.ts +++ b/src/cli/aws/agentcore-recommendation.ts @@ -15,6 +15,7 @@ * StartRecommendation, poll via GetRecommendation, and stop via * DeleteRecommendation (stop-via-delete pattern). */ +import { JobNotFoundError } from '../../lib'; import { getCredentialProvider } from './account'; import { dataPlaneEndpoint } from './stage-endpoint'; import { Sha256 } from '@aws-crypto/sha256-js'; @@ -54,7 +55,7 @@ export interface SessionSpan { spanId: string; } -/** Agent trace source — inline spans or CloudWatch Logs. */ +/** Agent trace source — inline spans, CloudWatch Logs, or batch evaluation. */ export interface AgentTracesSource { sessionSpans?: SessionSpan[]; cloudwatchLogs?: { @@ -65,6 +66,9 @@ export interface AgentTracesSource { limit?: number; sessionIds?: string[]; }; + batchEvaluation?: { + batchEvaluationArn: string; + }; } /** Evaluation config — exactly one evaluator as objective signal (API constraint: min 1, max 1). */ @@ -112,6 +116,7 @@ export interface RecommendationResultConfigurationBundle { export interface SystemPromptRecommendationResult { recommendedSystemPrompt?: string; configurationBundle?: RecommendationResultConfigurationBundle; + explanation?: string; errorCode?: string; errorMessage?: string; } @@ -119,6 +124,7 @@ export interface SystemPromptRecommendationResult { export interface ToolDescriptionRecommendationToolResult { toolName: string; recommendedToolDescription: string; + explanation?: string; } export interface ToolDescriptionRecommendationResult { @@ -265,7 +271,11 @@ async function signedRequest(options: { if (!response.ok) { const errorBody = await response.text(); - throw new Error(`Recommendation API error (${response.status}): ${errorBody} [requestId: ${requestId}]`); + const message = `Recommendation API error (${response.status}): ${errorBody} [requestId: ${requestId}]`; + if (response.status === 404) { + throw new JobNotFoundError(message, { errorSource: 'service' }); + } + throw new Error(message); } if (response.status === 204) return { data: {}, status: 204, requestId }; diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index ae7155d53..b91df356d 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -115,16 +115,22 @@ export function parseSSELine(line: string): { content: string | null; error: str if (!line.startsWith('data: ')) { return { content: null, error: null }; } - const content = line.slice(6); + const raw = line.slice(6); try { - const parsed: unknown = JSON.parse(content); + const parsed: unknown = JSON.parse(raw); if (typeof parsed === 'string') { return { content: parsed, error: null }; } else if (parsed && typeof parsed === 'object' && 'error' in parsed) { return { content: null, error: String((parsed as { error: unknown }).error) }; } + // ConverseStream-shaped event: extract text delta + const event = (parsed as { event?: { contentBlockDelta?: { delta?: { text?: string } } } })?.event; + const text = event?.contentBlockDelta?.delta?.text; + if (typeof text === 'string') { + return { content: text, error: null }; + } } catch { - return { content, error: null }; + return { content: raw, error: null }; } return { content: null, error: null }; } diff --git a/src/cli/aws/bedrock-agent.ts b/src/cli/aws/bedrock-agent.ts new file mode 100644 index 000000000..326adc197 --- /dev/null +++ b/src/cli/aws/bedrock-agent.ts @@ -0,0 +1,182 @@ +import { getCredentialProvider } from './account'; +import { + BedrockAgentClient, + type DataSource, + type DataSourceSummary, + GetDataSourceCommand, + GetIngestionJobCommand, + GetKnowledgeBaseCommand, + type IngestionJob, + type IngestionJobSummary, + type KnowledgeBase, + ListDataSourcesCommand, + ListIngestionJobsCommand, + StartIngestionJobCommand, +} from '@aws-sdk/client-bedrock-agent'; + +/** + * Region-scoped factory. Each call returns a fresh client; we don't pool because + * the CLI is a one-shot process and connection reuse provides marginal benefit. + */ +function makeClient(region: string): BedrockAgentClient { + return new BedrockAgentClient({ region, credentials: getCredentialProvider() }); +} + +function isNotFound(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const name = (err as { name?: string }).name; + return name === 'ResourceNotFoundException' || name === 'NotFoundException'; +} + +export interface KnowledgeBaseLookup { + region: string; + knowledgeBaseId: string; +} + +/** + * Fetch a knowledge base by ID. Returns null if the KB doesn't exist; rethrows + * any other error so the caller can decide how to surface it. + */ +export async function getKnowledgeBase(opts: KnowledgeBaseLookup): Promise { + const client = makeClient(opts.region); + try { + const response = await client.send(new GetKnowledgeBaseCommand({ knowledgeBaseId: opts.knowledgeBaseId })); + return response.knowledgeBase ?? null; + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } +} + +export interface DataSourceLookup extends KnowledgeBaseLookup { + dataSourceId: string; +} + +export async function getDataSource(opts: DataSourceLookup): Promise { + const client = makeClient(opts.region); + try { + const response = await client.send( + new GetDataSourceCommand({ knowledgeBaseId: opts.knowledgeBaseId, dataSourceId: opts.dataSourceId }) + ); + return response.dataSource ?? null; + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } +} + +/** + * List ingestion jobs for a (KB, DS) pair. Returns an empty array if the + * (KB, DS) pair doesn't exist or has no jobs. + * + * Paginates through every page (loops until `nextToken` is undefined) so the + * caller sees the full job history; the service caps a single page at 100 + * summaries by default, and a busy KB can have far more than that. + * + * Accepts an optional pre-built client so callers (e.g. {@link getLatestIngestionJob}) + * can avoid re-resolving credentials and re-establishing the TCP session for + * follow-up calls in the same chain. + */ +export async function listIngestionJobs( + opts: DataSourceLookup, + client: BedrockAgentClient = makeClient(opts.region) +): Promise { + const summaries: IngestionJobSummary[] = []; + let nextToken: string | undefined; + try { + do { + const response = await client.send( + new ListIngestionJobsCommand({ + knowledgeBaseId: opts.knowledgeBaseId, + dataSourceId: opts.dataSourceId, + nextToken, + }) + ); + if (response.ingestionJobSummaries) { + summaries.push(...response.ingestionJobSummaries); + } + nextToken = response.nextToken; + } while (nextToken); + return summaries; + } catch (err) { + if (isNotFound(err)) return []; + throw err; + } +} + +/** + * List the data sources attached to a knowledge base. Used post-deploy to + * resolve data-source IDs once CFN has settled (the L3 emits per-KB outputs + * but not per-DS outputs, so we look them up by listing the KB's children). + * + * Bedrock paginates this API; this function exhausts every page so callers + * never see a partial DS list. + * + * Returns an empty array if the KB doesn't exist or has no DSes. + */ +export async function listDataSources(opts: KnowledgeBaseLookup): Promise { + const client = makeClient(opts.region); + const summaries: DataSourceSummary[] = []; + let nextToken: string | undefined; + try { + do { + const response = await client.send( + new ListDataSourcesCommand({ knowledgeBaseId: opts.knowledgeBaseId, nextToken }) + ); + if (response.dataSourceSummaries) { + summaries.push(...response.dataSourceSummaries); + } + nextToken = response.nextToken; + } while (nextToken); + return summaries; + } catch (err) { + if (isNotFound(err)) return []; + throw err; + } +} + +/** + * Start a fresh ingestion job for a (KB, DS) pair. Surfaces all service errors + * verbatim — the caller decides how to format user-facing messages (e.g. via + * IngestionError). + */ +export async function startIngestionJob(opts: DataSourceLookup): Promise { + const client = makeClient(opts.region); + const response = await client.send( + new StartIngestionJobCommand({ knowledgeBaseId: opts.knowledgeBaseId, dataSourceId: opts.dataSourceId }) + ); + if (!response.ingestionJob) { + throw new Error('StartIngestionJob succeeded but returned no ingestion job in the response'); + } + return response.ingestionJob; +} + +/** + * List ingestion jobs and fetch the most recently started one's full details. + * Returns null if no jobs have ever run for this DS. + * + * Reuses a single BedrockAgentClient across the list + get calls so we don't + * resolve credentials twice for one logical lookup. + */ +export async function getLatestIngestionJob(opts: DataSourceLookup): Promise { + const client = makeClient(opts.region); + const summaries = await listIngestionJobs(opts, client); + if (summaries.length === 0) return null; + + const latest = summaries.reduce((best, current) => { + const bestStarted = best.startedAt?.getTime() ?? 0; + const currentStarted = current.startedAt?.getTime() ?? 0; + return currentStarted > bestStarted ? current : best; + }); + + if (!latest.ingestionJobId) return null; + + const response = await client.send( + new GetIngestionJobCommand({ + knowledgeBaseId: opts.knowledgeBaseId, + dataSourceId: opts.dataSourceId, + ingestionJobId: latest.ingestionJobId, + }) + ); + return response.ingestionJob ?? null; +} diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts index 2a143628d..09851e678 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -28,30 +28,19 @@ export { } from './policy-generation'; export { AgentCoreApiClient, AgentCoreApiError, type ApiClientOptions, type ApiPlane } from './api-client'; export { - createHarness, getHarness, - updateHarness, deleteHarness, - listHarnesses, - listAllHarnesses, invokeHarness, type Harness, - type HarnessSummary, type HarnessStatus, type HarnessStreamEvent, type HarnessStopReason, type TokenUsage, type StreamMetrics, - type CreateHarnessOptions, - type CreateHarnessResult, type GetHarnessOptions, type GetHarnessResult, - type UpdateHarnessOptions, - type UpdateHarnessResult, type DeleteHarnessOptions, type DeleteHarnessResult, - type ListHarnessesOptions, - type ListHarnessesResult, type InvokeHarnessOptions, } from './agentcore-harness'; export { @@ -71,6 +60,8 @@ export { mcpInitSession, mcpListTools, mcpCallTool, + parseSSE, + extractResult, stopRuntimeSession, type ExecuteBashOptions, type ExecuteBashResult, diff --git a/src/cli/cdk/toolkit-lib/wrapper.ts b/src/cli/cdk/toolkit-lib/wrapper.ts index 50a05a307..0ba5a474e 100644 --- a/src/cli/cdk/toolkit-lib/wrapper.ts +++ b/src/cli/cdk/toolkit-lib/wrapper.ts @@ -1,6 +1,7 @@ import { CONFIG_DIR } from '../../../lib'; import { CDK_APP_ENTRY, CDK_PROJECT_DIR } from '../../constants'; import { isChangesetInProgressError } from '../../errors'; +import { isPreviewEnabled } from '../../feature-flags'; import type { CdkToolkitWrapperOptions, DeployOptions, DestroyOptions, DiffOptions, ListOptions } from './types'; import { BaseCredentials, @@ -107,9 +108,18 @@ export class CdkToolkitWrapper { sdkConfig, }); + // The vended CDK app (dist/bin/cdk.js) runs as a child process and cannot see the + // build-time `__PREVIEW__` define baked into this CLI bundle. Pass the preview state + // through the child env so bin/cdk.ts can gate preview-only resources (e.g. harnesses) + // off when preview is disabled. The toolkit overlays this on top of process.env, so + // PATH/AWS_PROFILE are preserved; env must be unconditional so the flag is never + // dropped when no region override is present (absent flag defaults to off). this.cloudAssemblySource = await this.toolkit.fromCdkApp(this.getCdkAppCommand(), { workingDirectory: this.projectDir, - ...(region && { env: { AWS_REGION: region, AWS_DEFAULT_REGION: region } }), + env: { + AGENTCORE_PREVIEW: isPreviewEnabled() ? '1' : '0', + ...(region && { AWS_REGION: region, AWS_DEFAULT_REGION: region }), + }, }); }); } diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 38c87cf15..f7aa35f3b 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,8 +1,9 @@ import { getOrCreateInstallationId } from '../lib/schemas/io/global-config'; -import { registerABTestCommand } from './commands/abtest'; import { registerAdd } from './commands/add'; +import { registerAddSkill } from './commands/add/skill-command'; import { registerAddTool } from './commands/add/tool-command'; import { registerArchive } from './commands/archive'; +import { registerBatchEvaluations } from './commands/batch-evaluations'; import { registerConfig } from './commands/config'; import { registerConfigBundle } from './commands/config-bundle'; import { registerCreate } from './commands/create'; @@ -11,6 +12,7 @@ import { registerDeploy } from './commands/deploy'; import { registerDev } from './commands/dev'; import { registerEval } from './commands/eval'; import { registerExec } from './commands/exec'; +import { registerExport } from './commands/export'; import { registerFeedback } from './commands/feedback'; import { registerFetch } from './commands/fetch'; import { registerHelp } from './commands/help'; @@ -18,9 +20,10 @@ import { registerImport } from './commands/import'; import { registerInvoke } from './commands/invoke'; import { registerLogs } from './commands/logs'; import { registerPackage } from './commands/package'; -import { registerPause, registerPromote } from './commands/pause'; -import { registerRecommendations } from './commands/recommendations'; +import { registerPause } from './commands/pause'; +import { registerPromote } from './commands/promote'; import { registerRemove } from './commands/remove'; +import { registerRemoveSkill } from './commands/remove/skill-command'; import { registerRemoveTool } from './commands/remove/tool-command'; import { registerResume } from './commands/resume'; import { registerRun } from './commands/run'; @@ -30,6 +33,7 @@ import { registerTelemetry } from './commands/telemetry'; import { registerTraces } from './commands/traces'; import { registerUpdate } from './commands/update'; import { registerValidate } from './commands/validate'; +import { registerView } from './commands/view'; import { COMMAND_DESCRIPTIONS, PACKAGE_VERSION } from './constants'; import { isPreviewEnabled } from './feature-flags'; import { printPostCommandNotices, printTelemetryNotice } from './notices'; @@ -102,7 +106,8 @@ export function registerCommands(program: Command) { registerLogs(program); registerPackage(program); registerPause(program); - registerRecommendations(program); + registerView(program); + registerBatchEvaluations(program); const removeCmd = registerRemove(program); registerResume(program); registerRun(program); @@ -117,6 +122,10 @@ export function registerCommands(program: Command) { registerConfig(program); registerDataset(program); registerArchive(program); + // Register export command (preview-only) + if (isPreviewEnabled()) { + registerExport(program); + } // Register primitive subcommands (add agent, remove agent, add memory, etc.) for (const primitive of ALL_PRIMITIVES) { @@ -127,10 +136,9 @@ export function registerCommands(program: Command) { if (isPreviewEnabled()) { registerAddTool(addCmd); registerRemoveTool(removeCmd); + registerAddSkill(addCmd); + registerRemoveSkill(removeCmd); } - - // Register AB test detail command - registerABTestCommand(program); } export const main = async (argv: string[]) => { diff --git a/src/cli/cloudformation/__tests__/outputs-config-bundles.test.ts b/src/cli/cloudformation/__tests__/outputs-config-bundles.test.ts new file mode 100644 index 000000000..b6fb7d800 --- /dev/null +++ b/src/cli/cloudformation/__tests__/outputs-config-bundles.test.ts @@ -0,0 +1,82 @@ +import { parseConfigBundleOutputs } from '../outputs'; +import { describe, expect, it } from 'vitest'; + +describe('parseConfigBundleOutputs', () => { + it('parses BundleId, BundleArn, and VersionId from stack outputs', () => { + const outputs = { + ApplicationConfigBundleMyBundleIdOutputABC123: 'myBundle-abc123def', + ApplicationConfigBundleMyBundleArnOutputDEF456: + 'arn:aws:bedrock-agentcore:us-west-2:123456789012:configuration-bundle/myBundle-abc123def', + ApplicationConfigBundleMyBundleVersionIdOutput789: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }; + + const result = parseConfigBundleOutputs(outputs, ['MyBundle']); + + expect(result).toEqual({ + MyBundle: { + bundleId: 'myBundle-abc123def', + bundleArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:configuration-bundle/myBundle-abc123def', + versionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }, + }); + }); + + it('parses multiple config bundles', () => { + const outputs = { + ApplicationConfigBundleFirstIdOutputAAA: 'first-id', + ApplicationConfigBundleFirstArnOutputBBB: 'arn:first', + ApplicationConfigBundleFirstVersionIdOutputCCC: 'version-1', + ApplicationConfigBundleSecondIdOutputDDD: 'second-id', + ApplicationConfigBundleSecondArnOutputEEE: 'arn:second', + ApplicationConfigBundleSecondVersionIdOutputFFF: 'version-2', + }; + + const result = parseConfigBundleOutputs(outputs, ['First', 'Second']); + + expect(Object.keys(result)).toHaveLength(2); + expect(result.First!.bundleId).toBe('first-id'); + expect(result.Second!.bundleId).toBe('second-id'); + }); + + it('skips bundle when Id output is missing', () => { + const outputs = { + ApplicationConfigBundleMyBundleArnOutputDEF: 'arn:test', + ApplicationConfigBundleMyBundleVersionIdOutput123: 'v1', + }; + + const result = parseConfigBundleOutputs(outputs, ['MyBundle']); + + expect(result).toEqual({}); + }); + + it('skips bundle when VersionId output is missing', () => { + const outputs = { + ApplicationConfigBundleMyBundleIdOutputABC: 'id-123', + ApplicationConfigBundleMyBundleArnOutputDEF: 'arn:test', + }; + + const result = parseConfigBundleOutputs(outputs, ['MyBundle']); + + expect(result).toEqual({}); + }); + + it('returns empty record when no matching outputs exist', () => { + const outputs = { + ApplicationAgentMyAgentRuntimeIdOutputXYZ: 'rt-123', + }; + + const result = parseConfigBundleOutputs(outputs, ['MyBundle']); + + expect(result).toEqual({}); + }); + + it('returns empty record for empty bundle names list', () => { + const outputs = { + ApplicationConfigBundleMyBundleIdOutputABC: 'id-123', + }; + + const result = parseConfigBundleOutputs(outputs, []); + + expect(result).toEqual({}); + }); +}); diff --git a/src/cli/cloudformation/__tests__/outputs-extended.test.ts b/src/cli/cloudformation/__tests__/outputs-extended.test.ts index 1f48faa96..f3d06fe58 100644 --- a/src/cli/cloudformation/__tests__/outputs-extended.test.ts +++ b/src/cli/cloudformation/__tests__/outputs-extended.test.ts @@ -213,6 +213,60 @@ describe('buildDeployedState', () => { expect(state.targets.default!.resources?.stackName).toBe('NewStack'); }); + describe('abTests carry-forward', () => { + const existingWithABTests = { + targets: { + default: { + resources: { + runtimes: {}, + stackName: 'Stack', + abTests: { + live_test: { abTestId: 'ab-live', abTestArn: 'arn:ab-live' }, + stale_test: { abTestId: 'ab-stale', abTestArn: 'arn:ab-stale' }, + }, + }, + }, + }, + }; + + it('carries forward all abTests when abTestNames is omitted (legacy behavior)', () => { + const state = buildDeployedState({ + targetName: 'default', + stackName: 'Stack', + agents: {}, + gateways: {}, + existingState: existingWithABTests, + }); + expect(state.targets.default!.resources?.abTests).toEqual(existingWithABTests.targets.default.resources.abTests); + }); + + it('prunes abTests not present in the current project spec', () => { + const state = buildDeployedState({ + targetName: 'default', + stackName: 'Stack', + agents: {}, + gateways: {}, + existingState: existingWithABTests, + abTestNames: ['live_test'], + }); + expect(state.targets.default!.resources?.abTests).toEqual({ + live_test: { abTestId: 'ab-live', abTestArn: 'arn:ab-live' }, + }); + }); + + it('omits abTests entirely when none of the existing tests remain in the spec', () => { + const state = buildDeployedState({ + targetName: 'default', + stackName: 'Stack', + agents: {}, + gateways: {}, + existingState: existingWithABTests, + abTestNames: [], + }); + expect(state.targets.default!.resources?.abTests).toBeUndefined(); + }); + }); + it('includes identityKmsKeyArn when provided', () => { const state = buildDeployedState({ targetName: 'default', diff --git a/src/cli/cloudformation/__tests__/outputs-harness.test.ts b/src/cli/cloudformation/__tests__/outputs-harness.test.ts new file mode 100644 index 000000000..b97332c12 --- /dev/null +++ b/src/cli/cloudformation/__tests__/outputs-harness.test.ts @@ -0,0 +1,138 @@ +import type { DeployedState, HarnessDeployedState } from '../../../schema'; +import { toPascalId } from '../logical-ids'; +import { buildDeployedState, parseHarnessOutputs } from '../outputs'; +import { describe, expect, it, vi } from 'vitest'; + +/** Silent onWarn sink for cases where the warning is not under test. */ +const noWarn = vi.fn(); + +/** Build the four CDK output keys for a harness, mirroring the L3's output naming. */ +function harnessOutputs( + name: string, + overrides: Partial> = {} +) { + const p = toPascalId('Harness', name); + const out: Record = {}; + const def = { + Id: `h-${name}`, + Arn: `arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/h-${name}`, + Status: 'READY', + RoleRoleArn: `arn:aws:iam::111122223333:role/${name}`, + AgentRuntimeArn: `arn:aws:bedrock-agentcore:us-west-2:111122223333:runtime/rt-${name}`, + }; + const merged = { ...def, ...overrides }; + for (const [seg, val] of Object.entries(merged)) { + if (val === undefined) continue; + out[`Application${p}${seg}Output${seg}Hash`] = val; + } + return out; +} + +describe('parseHarnessOutputs', () => { + it('parses a complete harness into a cloudformation-marked record', () => { + const result = parseHarnessOutputs(harnessOutputs('h1'), ['h1'], noWarn); + expect(result.h1).toMatchObject({ + harnessId: 'h-h1', + status: 'READY', + provisioner: 'cloudformation', + }); + expect(result.h1!.agentRuntimeArn).toContain('runtime/rt-h1'); + }); + + it('warns and skips when a harness produced no outputs', () => { + const onWarn = vi.fn(); + const result = parseHarnessOutputs({}, ['ghost'], onWarn); + expect(result.ghost).toBeUndefined(); + expect(onWarn).toHaveBeenCalledTimes(1); + expect(onWarn.mock.calls[0]![0]).toContain('produced no CloudFormation outputs'); + }); + + it('warns naming the missing key when a harness is partially emitted', () => { + const onWarn = vi.fn(); + // Drop the RoleRoleArn output → partial. + const outputs = harnessOutputs('h1', { RoleRoleArn: undefined }); + const result = parseHarnessOutputs(outputs, ['h1'], onWarn); + expect(result.h1).toBeUndefined(); + expect(onWarn).toHaveBeenCalledTimes(1); + expect(onWarn.mock.calls[0]![0]).toContain('RoleArn'); + }); + + it('defaults onWarn to console.warn without throwing', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + parseHarnessOutputs({}, ['ghost']); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +describe('buildDeployedState — harness carry-forward', () => { + const orphan: HarnessDeployedState = { + harnessId: 'h-orphan', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/h-orphan', + roleArn: 'arn:aws:iam::111122223333:role/orphan', + status: 'READY', + // no provisioner marker → orphan + }; + const markedExisting: HarnessDeployedState = { + harnessId: 'h-old-cfn', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/h-old-cfn', + roleArn: 'arn:aws:iam::111122223333:role/oldcfn', + status: 'READY', + provisioner: 'cloudformation', + }; + + function existingStateWith(harnesses: Record): DeployedState { + return { targets: { default: { resources: { stackName: 'S', harnesses } } } }; + } + + it('preserves an existing orphan that the current outputs do not cover', () => { + const result = buildDeployedState({ + targetName: 'default', + stackName: 'S', + agents: {}, + gateways: {}, + existingState: existingStateWith({ legacy: orphan }), + harnesses: parseHarnessOutputs(harnessOutputs('h1'), ['h1'], noWarn), + }); + const harnesses = result.targets.default!.resources?.harnesses ?? {}; + expect(harnesses.legacy).toMatchObject({ harnessId: 'h-orphan' }); + expect(harnesses.h1).toMatchObject({ provisioner: 'cloudformation' }); + }); + + it('lets a freshly-parsed CFN record win on key conflict (carries the marker)', () => { + const result = buildDeployedState({ + targetName: 'default', + stackName: 'S', + agents: {}, + gateways: {}, + existingState: existingStateWith({ h1: orphan }), + harnesses: parseHarnessOutputs(harnessOutputs('h1'), ['h1'], noWarn), + }); + const h1 = result.targets.default!.resources?.harnesses?.h1; + expect(h1?.provisioner).toBe('cloudformation'); + expect(h1?.harnessId).toBe('h-h1'); + }); + + it('drops a previously CFN-managed harness that is no longer in the outputs (CFN deleted it)', () => { + const result = buildDeployedState({ + targetName: 'default', + stackName: 'S', + agents: {}, + gateways: {}, + existingState: existingStateWith({ removed: markedExisting }), + harnesses: {}, + }); + expect(result.targets.default!.resources?.harnesses).toBeUndefined(); + }); + + it('does not create a harnesses key when there are neither outputs nor existing orphans', () => { + const result = buildDeployedState({ + targetName: 'default', + stackName: 'S', + agents: {}, + gateways: {}, + harnesses: {}, + }); + expect(result.targets.default!.resources?.harnesses).toBeUndefined(); + }); +}); diff --git a/src/cli/cloudformation/__tests__/outputs.test.ts b/src/cli/cloudformation/__tests__/outputs.test.ts index 24f39b451..208ad95fc 100644 --- a/src/cli/cloudformation/__tests__/outputs.test.ts +++ b/src/cli/cloudformation/__tests__/outputs.test.ts @@ -1,11 +1,12 @@ import { buildDeployedState, parseGatewayOutputs, + parseHarnessOutputs, parseMemoryOutputs, parsePolicyEngineOutputs, parsePolicyOutputs, } from '../outputs'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; describe('buildDeployedState', () => { it('persists identityKmsKeyArn when provided', () => { @@ -292,6 +293,34 @@ describe('parseMemoryOutputs', () => { }); }); +describe('parseHarnessOutputs', () => { + const completeOutputs = (name: string, extra: Record = {}) => ({ + [`ApplicationHarness${name}IdOutputAAA`]: `h-${name}`, + [`ApplicationHarness${name}ArnOutputBBB`]: `arn:aws:bedrock-agentcore:us-east-1:1:harness/h-${name}`, + [`ApplicationHarness${name}StatusOutputCCC`]: 'READY', + [`ApplicationHarness${name}RoleRoleArnOutputDDD`]: 'arn:aws:iam::1:role/r', + ...extra, + }); + + it('captures harnessVersion from the Version output', () => { + const outputs = completeOutputs('Support', { ApplicationHarnessSupportVersionOutputEEE: '3' }); + const result = parseHarnessOutputs(outputs, ['Support'], vi.fn()); + expect(result.Support?.harnessVersion).toBe(3); + }); + + it('leaves harnessVersion unset when no Version output is present (legacy stack)', () => { + const result = parseHarnessOutputs(completeOutputs('Support'), ['Support'], vi.fn()); + expect(result.Support).toBeDefined(); + expect(result.Support?.harnessVersion).toBeUndefined(); + }); + + it('ignores a non-numeric Version output', () => { + const outputs = completeOutputs('Support', { ApplicationHarnessSupportVersionOutputEEE: 'not-a-number' }); + const result = parseHarnessOutputs(outputs, ['Support'], vi.fn()); + expect(result.Support?.harnessVersion).toBeUndefined(); + }); +}); + describe('parsePolicyEngineOutputs', () => { it('extracts policy engine outputs matching pattern', () => { const outputs = { @@ -504,32 +533,21 @@ describe('buildDeployedState carry-forward', () => { }); }); - it('carries forward httpGateways from existing state', () => { - const existingState = { - targets: { - default: { - resources: { - stackName: 'TestStack', - httpGateways: { - MyHttpGw: { - gatewayId: 'hgw-456', - gatewayArn: 'arn:aws:bedrock:us-east-1:123456789012:http-gateway/hgw-456', - }, - }, - }, - }, - }, - }; - + it('populates resources.gateways from httpGateways parameter', () => { const result = buildDeployedState({ targetName: 'default', stackName: 'TestStack', agents: {}, gateways: {}, - existingState, + httpGateways: { + MyHttpGw: { + gatewayId: 'hgw-456', + gatewayArn: 'arn:aws:bedrock:us-east-1:123456789012:http-gateway/hgw-456', + }, + }, }); - expect(result.targets.default!.resources?.httpGateways).toEqual({ + expect(result.targets.default!.resources?.gateways).toEqual({ MyHttpGw: { gatewayId: 'hgw-456', gatewayArn: 'arn:aws:bedrock:us-east-1:123456789012:http-gateway/hgw-456', @@ -560,26 +578,15 @@ describe('buildDeployedState carry-forward', () => { expect(result.targets.default!.resources?.abTests).toBeUndefined(); }); - it('does not carry forward empty httpGateways', () => { - const existingState = { - targets: { - default: { - resources: { - stackName: 'TestStack', - httpGateways: {}, - }, - }, - }, - }; - + it('does not populate resources.gateways when httpGateways param is empty', () => { const result = buildDeployedState({ targetName: 'default', stackName: 'TestStack', agents: {}, gateways: {}, - existingState, + httpGateways: {}, }); - expect(result.targets.default!.resources?.httpGateways).toBeUndefined(); + expect(result.targets.default!.resources?.gateways).toBeUndefined(); }); }); diff --git a/src/cli/cloudformation/__tests__/parse-kb-outputs.test.ts b/src/cli/cloudformation/__tests__/parse-kb-outputs.test.ts new file mode 100644 index 000000000..92b18363d --- /dev/null +++ b/src/cli/cloudformation/__tests__/parse-kb-outputs.test.ts @@ -0,0 +1,69 @@ +import { parseKnowledgeBaseDataSourceOutputs, parseKnowledgeBaseOutputs } from '../outputs'; +import { describe, expect, it } from 'vitest'; + +describe('parseKnowledgeBaseOutputs', () => { + it('hydrates dataSources[] from per-DS CFN outputs (L3 #234+)', () => { + const outputs = { + ApplicationKnowledgeBaseProductDocsIdOutput06769C35: 'KB1', + ApplicationKnowledgeBaseProductDocsArnOutput9B6F9B44: 'arn:aws:bedrock:us-west-2:0:knowledge-base/KB1', + ApplicationKnowledgeBaseProductDocsDataSource0IdOutput750CF2FE: 'DS-A', + ApplicationKnowledgeBaseProductDocsDataSource0UriOutput07D6B66D: 's3://bucket-a/docs/', + ApplicationKnowledgeBaseProductDocsDataSource1IdOutput9DF50FA0: 'DS-B', + ApplicationKnowledgeBaseProductDocsDataSource1UriOutputAA112233: 's3://bucket-b/', + }; + const result = parseKnowledgeBaseOutputs(outputs, ['product-docs']); + expect(result['product-docs']).toEqual({ + knowledgeBaseId: 'KB1', + knowledgeBaseArn: 'arn:aws:bedrock:us-west-2:0:knowledge-base/KB1', + dataSources: [ + { dataSourceId: 'DS-A', uri: 's3://bucket-a/docs/' }, + { dataSourceId: 'DS-B', uri: 's3://bucket-b/' }, + ], + }); + }); + + it('returns empty dataSources[] when per-DS outputs are absent (older L3)', () => { + const outputs = { + ApplicationKnowledgeBaseProductDocsIdOutput06769C35: 'KB1', + ApplicationKnowledgeBaseProductDocsArnOutput9B6F9B44: 'arn:x', + }; + const result = parseKnowledgeBaseOutputs(outputs, ['product-docs']); + expect(result['product-docs']?.dataSources).toEqual([]); + }); + + it('omits a KB whose Id/Arn outputs are missing entirely', () => { + const outputs = { + SomeOtherOutput: 'irrelevant', + }; + const result = parseKnowledgeBaseOutputs(outputs, ['product-docs']); + expect(result['product-docs']).toBeUndefined(); + }); +}); + +describe('parseKnowledgeBaseDataSourceOutputs', () => { + it('orders entries by index even when stack outputs come back unordered', () => { + const outputs = { + ApplicationKnowledgeBaseDocsDataSource2IdOutputAAAAAAAA: 'DS-2', + ApplicationKnowledgeBaseDocsDataSource0IdOutputBBBBBBBB: 'DS-0', + ApplicationKnowledgeBaseDocsDataSource1IdOutputCCCCCCCC: 'DS-1', + ApplicationKnowledgeBaseDocsDataSource0UriOutputDDDDDDDD: 's3://0/', + ApplicationKnowledgeBaseDocsDataSource1UriOutputEEEEEEEE: 's3://1/', + ApplicationKnowledgeBaseDocsDataSource2UriOutputFFFFFFFF: 's3://2/', + }; + expect(parseKnowledgeBaseDataSourceOutputs(outputs, 'docs')).toEqual([ + { dataSourceId: 'DS-0', uri: 's3://0/' }, + { dataSourceId: 'DS-1', uri: 's3://1/' }, + { dataSourceId: 'DS-2', uri: 's3://2/' }, + ]); + }); + + it('drops orphan entries (Id without Uri or vice versa)', () => { + const outputs = { + ApplicationKnowledgeBaseDocsDataSource0IdOutputAAAAAAAA: 'DS-0', + // no DataSource0UriOutput + ApplicationKnowledgeBaseDocsDataSource1IdOutputBBBBBBBB: 'DS-1', + ApplicationKnowledgeBaseDocsDataSource1UriOutputCCCCCCCC: 's3://1/', + }; + expect(parseKnowledgeBaseDataSourceOutputs(outputs, 'docs')).toEqual([{ dataSourceId: 'DS-1', uri: 's3://1/' }]); + }); +}); diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 132aa7d0b..d8463a023 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -1,8 +1,11 @@ import type { AgentCoreDeployedState, + ConfigBundleDeployedState, DatasetDeployedState, DeployedState, EvaluatorDeployedState, + HarnessDeployedState, + KnowledgeBaseDeployedState, MemoryDeployedState, OnlineEvalDeployedState, PaymentDeployedState, @@ -42,16 +45,26 @@ export async function getStackOutputs(region: string, stackName: string): Promis * Parse stack outputs into deployed state for gateways. * * Output key pattern for gateways: - * Gateway{GatewayName}UrlOutput{Hash} + * Gateway{GatewayName}(Id|Arn|Url)Output{Hash} + * + * Output key pattern for gateway targets: + * GatewayTarget{TargetName}IdOutput{Hash} * * Examples: * - GatewayMyGatewayUrlOutput3E11FAB4 + * - GatewayTargetMyTargetIdOutputA1B2C3D4 */ export function parseGatewayOutputs( outputs: StackOutputs, gatewaySpecs: Record -): Record { - const gateways: Record = {}; +): Record< + string, + { gatewayId: string; gatewayArn: string; gatewayUrl?: string; targets?: Record } +> { + const gateways: Record< + string, + { gatewayId: string; gatewayArn: string; gatewayUrl?: string; targets?: Record } + > = {}; // Map PascalCase gateway names to original names for lookup const gatewayNames = Object.keys(gatewaySpecs); @@ -59,8 +72,23 @@ export function parseGatewayOutputs( // Match pattern: Gateway{Name}{Type}Output{Hash} const outputPattern = /^Gateway(.+?)(Id|Arn|Url)Output/; + // Match pattern: GatewayTarget{TargetName}IdOutput{Hash} + const targetOutputPattern = /^GatewayTarget(.+?)IdOutput/; + + // Collect target outputs separately + const targetOutputs: { logicalTarget: string; targetId: string }[] = []; for (const [key, value] of Object.entries(outputs)) { + // Check target pattern first (more specific) to avoid false matches with gateway pattern + const targetMatch = targetOutputPattern.exec(key); + if (targetMatch) { + const logicalTarget = targetMatch[1]; + if (logicalTarget) { + targetOutputs.push({ logicalTarget, targetId: value }); + } + continue; + } + const match = outputPattern.exec(key); if (!match) continue; @@ -82,6 +110,32 @@ export function parseGatewayOutputs( } } + // Associate target outputs with gateways + // Build a map from PascalCase target name to [gatewayName, originalTargetName] + const targetToGateway = new Map(); + for (const gwName of gatewayNames) { + const gwSpec = gatewaySpecs[gwName]; + if ( + gwSpec && + typeof gwSpec === 'object' && + 'targets' in gwSpec && + Array.isArray((gwSpec as { targets?: unknown[] }).targets) + ) { + for (const target of (gwSpec as { targets: { name: string }[] }).targets) { + targetToGateway.set(toPascalId(target.name), { gatewayName: gwName, targetName: target.name }); + } + } + } + + for (const { logicalTarget, targetId } of targetOutputs) { + const mapping = targetToGateway.get(logicalTarget); + const gwState = mapping ? gateways[mapping.gatewayName] : undefined; + if (mapping && gwState) { + gwState.targets ??= {}; + gwState.targets[mapping.targetName] = { targetId }; + } + } + return gateways; } @@ -214,6 +268,81 @@ export function parseMemoryOutputs(outputs: StackOutputs, memoryNames: string[]) return memories; } +/** + * Parse stack outputs into deployed state for knowledge bases. + * + * Output key patterns (L3 ≥ #234): + * ApplicationKnowledgeBase{Pascal}(Id|Arn)Output{Hash} + * ApplicationKnowledgeBase{Pascal}DataSource{N}(Id|Uri)Output{Hash} + * + * Per-DS outputs are how we map URI → deployed DS id deterministically. For + * stacks deployed against an older L3 that pre-dates those outputs, the map + * comes back empty — callers fall back to ListDataSources. + * + * `sourcesHash` is populated separately by the post-deploy step. + */ +export function parseKnowledgeBaseOutputs( + outputs: StackOutputs, + knowledgeBaseNames: string[] +): Record { + const knowledgeBases: Record = {}; + const outputKeys = Object.keys(outputs); + + for (const kbName of knowledgeBaseNames) { + const pascal = toPascalId('KnowledgeBase', kbName); + const idPrefix = `Application${pascal}IdOutput`; + const arnPrefix = `Application${pascal}ArnOutput`; + + const idKey = outputKeys.find(k => k.startsWith(idPrefix)); + const arnKey = outputKeys.find(k => k.startsWith(arnPrefix)); + + if (idKey && arnKey) { + knowledgeBases[kbName] = { + knowledgeBaseId: outputs[idKey]!, + knowledgeBaseArn: outputs[arnKey]!, + dataSources: parseKnowledgeBaseDataSourceOutputs(outputs, kbName), + }; + } + } + + return knowledgeBases; +} + +/** + * Parse the per-DataSource CFN outputs for a single KB into an ordered + * `[{dataSourceId, uri}]` array. Outputs are paired by index (DataSource{N}Id + * + DataSource{N}Uri) and sorted ascending by N so the result mirrors the + * local `dataSources[]` order from agentcore.json. + * + * Returns an empty array when no per-DS outputs are present (e.g. stack + * deployed against an older L3) — callers should fall back to a SDK listing. + */ +export function parseKnowledgeBaseDataSourceOutputs( + outputs: StackOutputs, + knowledgeBaseName: string +): { dataSourceId: string; uri: string }[] { + const pascal = toPascalId('KnowledgeBase', knowledgeBaseName); + const indexed = new Map(); + // Match `Application{Pascal}DataSource{N}IdOutput…` and `…UriOutput…`. + const pattern = new RegExp(`^Application${pascal}DataSource(\\d+)(Id|Uri)Output`); + + for (const [key, value] of Object.entries(outputs)) { + const match = pattern.exec(key); + if (!match) continue; + const idx = parseInt(match[1]!, 10); + const kind = match[2] as 'Id' | 'Uri'; + const slot = indexed.get(idx) ?? {}; + if (kind === 'Id') slot.dataSourceId = value; + else slot.uri = value; + indexed.set(idx, slot); + } + + return [...indexed.entries()] + .sort(([a], [b]) => a - b) + .map(([, slot]) => slot) + .filter((slot): slot is { dataSourceId: string; uri: string } => !!slot.dataSourceId && !!slot.uri); +} + /** * Parse stack outputs into deployed state for evaluators. * @@ -408,6 +537,103 @@ export function parseDatasetOutputs( return datasets; } +/** + * Parse CDK stack outputs for CFN-deployed harnesses into deployed-state records. + * + * The L3 AgentCoreApplication emits, per harness `${name}` (pascal = toPascalId('Harness', name)): + * ApplicationHarness{Pascal}{Id,Arn,Status,AgentRuntimeArn}Output + * and the execution role (AgentCoreHarnessRole) separately emits: + * ApplicationHarness{Pascal}RoleRoleArnOutput + * The 'Arn' harness prefix does not collide with 'RoleRoleArn' (next segment differs). + */ +export function parseHarnessOutputs( + outputs: StackOutputs, + harnessNames: string[], + onWarn: (message: string) => void = console.warn +): Record { + const harnesses: Record = {}; + const outputKeys = Object.keys(outputs); + + for (const harnessName of harnessNames) { + const pascal = toPascalId('Harness', harnessName); + const idKey = outputKeys.find(k => k.startsWith(`Application${pascal}IdOutput`)); + const arnKey = outputKeys.find(k => k.startsWith(`Application${pascal}ArnOutput`)); + const statusKey = outputKeys.find(k => k.startsWith(`Application${pascal}StatusOutput`)); + const runtimeArnKey = outputKeys.find(k => k.startsWith(`Application${pascal}AgentRuntimeArnOutput`)); + const roleArnKey = outputKeys.find(k => k.startsWith(`Application${pascal}RoleRoleArnOutput`)); + // Version is OPTIONAL: stacks deployed before the config-versioning change won't emit it, so it + // is never part of the required-set guard below — a missing Version just leaves harnessVersion unset. + const versionKey = outputKeys.find(k => k.startsWith(`Application${pascal}VersionOutput`)); + const versionRaw = versionKey ? outputs[versionKey] : undefined; + const harnessVersion = versionRaw !== undefined && /^[0-9]+$/.test(versionRaw) ? Number(versionRaw) : undefined; + + // Id/Arn/Status/RoleArn are required for a complete CDK-managed harness record. + if (idKey && arnKey && statusKey && roleArnKey) { + harnesses[harnessName] = { + harnessId: outputs[idKey]!, + harnessArn: outputs[arnKey]!, + status: outputs[statusKey]!, + roleArn: outputs[roleArnKey]!, + ...(harnessVersion !== undefined && { harnessVersion }), + ...(runtimeArnKey && { agentRuntimeArn: outputs[runtimeArnKey] }), + provisioner: 'cloudformation', + }; + continue; + } + + // A spec'd harness that produced incomplete (or no) outputs is dropped from + // deployed-state, which silently removes it from `status`/`invoke`. Surface + // the gap so a partially-emitted or missing harness leaves a trace rather + // than vanishing without explanation. + const missing = [!idKey && 'Id', !arnKey && 'Arn', !statusKey && 'Status', !roleArnKey && 'RoleArn'].filter( + (v): v is string => typeof v === 'string' + ); + if (missing.length === 4) { + onWarn( + `Harness "${harnessName}" produced no CloudFormation outputs; it will not appear in ` + + `\`agentcore status\` or be invocable until the next successful deploy.` + ); + } else { + onWarn( + `Harness "${harnessName}" is missing CloudFormation output(s): ${missing.join(', ')}. ` + + `Skipping it in deployed-state — it will not appear in \`agentcore status\` or be invocable. ` + + `Re-run \`agentcore deploy\`; if this persists, the harness stack output template may be malformed.` + ); + } + } + + return harnesses; +} + +export function parseConfigBundleOutputs( + outputs: StackOutputs, + bundleNames: string[] +): Record { + const bundles: Record = {}; + const outputKeys = Object.keys(outputs); + + for (const bundleName of bundleNames) { + const pascal = toPascalId('ConfigBundle', bundleName); + const idPrefix = `Application${pascal}IdOutput`; + const arnPrefix = `Application${pascal}ArnOutput`; + const versionPrefix = `Application${pascal}VersionIdOutput`; + + const idKey = outputKeys.find(k => k.startsWith(idPrefix)); + const arnKey = outputKeys.find(k => k.startsWith(arnPrefix)); + const versionKey = outputKeys.find(k => k.startsWith(versionPrefix)); + + if (idKey && arnKey && versionKey) { + bundles[bundleName] = { + bundleId: outputs[idKey]!, + bundleArn: outputs[arnKey]!, + versionId: outputs[versionKey]!, + }; + } + } + + return bundles; +} + /** * Strip underscores from a name to produce a valid CDK logical ID segment. * Must match the toCdkId() function in the vended cdk-stack.ts. @@ -480,6 +706,10 @@ export interface BuildDeployedStateOptions { stackName: string; agents: Record; gateways: Record; + httpGateways?: Record< + string, + { gatewayId: string; gatewayArn: string; gatewayUrl?: string; targets?: Record } + >; existingState?: DeployedState; identityKmsKeyArn?: string; credentials?: Record; @@ -489,20 +719,19 @@ export interface BuildDeployedStateOptions { policyEngines?: Record; policies?: Record; runtimeEndpoints?: Record; - harnesses?: Record< - string, - { - harnessId: string; - harnessArn: string; - roleArn: string; - status: string; - agentRuntimeArn?: string; - memoryArn?: string; - configHash?: string; - } - >; + harnesses?: Record; datasets?: Record; + configBundles?: Record; + knowledgeBases?: Record; payments?: Record; + /** + * Names of A/B tests currently declared in the project spec. AB test state is managed + * post-deploy (not via CFN outputs) and carried forward across deploys; passing the + * current spec names lets us prune entries for tests the user has since removed, so + * stale (e.g. preview) entries self-heal instead of lingering in deployed-state. + * If omitted, all existing AB test entries are carried forward unchanged. + */ + abTestNames?: string[]; } /** @@ -514,6 +743,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta stackName, agents, gateways, + httpGateways, existingState, identityKmsKeyArn, credentials, @@ -525,7 +755,10 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta runtimeEndpoints, harnesses, datasets, + configBundles, + knowledgeBases, payments, + abTestNames, } = opts; const targetState: TargetDeployedState = { resources: { @@ -545,6 +778,11 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta }; } + // Add HTTP gateway state if HTTP gateways exist + if (httpGateways && Object.keys(httpGateways).length > 0) { + targetState.resources!.gateways = httpGateways; + } + // Add credential state if credentials exist if (credentials && Object.keys(credentials).length > 0) { targetState.resources!.credentials = credentials; @@ -569,27 +807,52 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta targetState.resources!.datasets = datasets; } - // Carry forward config bundles from existing state (managed post-deploy, not via CFN outputs) - const existingConfigBundles = existingState?.targets?.[targetName]?.resources?.configBundles; - if (existingConfigBundles && Object.keys(existingConfigBundles).length > 0) { - targetState.resources!.configBundles = existingConfigBundles; + if (knowledgeBases && Object.keys(knowledgeBases).length > 0) { + targetState.resources!.knowledgeBases = knowledgeBases; } - // Carry forward AB tests from existing state (managed post-deploy, not via CFN outputs) + // Config bundles from CFN outputs (preferred) or carry forward from existing state (legacy) + if (configBundles && Object.keys(configBundles).length > 0) { + targetState.resources!.configBundles = configBundles; + } else { + const existingConfigBundles = existingState?.targets?.[targetName]?.resources?.configBundles; + if (existingConfigBundles && Object.keys(existingConfigBundles).length > 0) { + targetState.resources!.configBundles = existingConfigBundles; + } + } + + // Carry forward AB tests from existing state (managed post-deploy, not via CFN outputs). + // Prune entries for tests no longer declared in the project spec so stale (e.g. preview) + // entries self-heal. When abTestNames is undefined, carry forward everything unchanged. const existingABTests = existingState?.targets?.[targetName]?.resources?.abTests; if (existingABTests && Object.keys(existingABTests).length > 0) { - targetState.resources!.abTests = existingABTests; + const carriedABTests = + abTestNames === undefined + ? existingABTests + : Object.fromEntries(Object.entries(existingABTests).filter(([specName]) => abTestNames.includes(specName))); + if (Object.keys(carriedABTests).length > 0) { + targetState.resources!.abTests = carriedABTests; + } } - // Carry forward HTTP gateways from existing state (managed post-deploy, not via CFN outputs) - const existingHttpGateways = existingState?.targets?.[targetName]?.resources?.httpGateways; - if (existingHttpGateways && Object.keys(existingHttpGateways).length > 0) { - targetState.resources!.httpGateways = existingHttpGateways; + // Merge harness state. CFN-sourced records (freshly parsed, stamped + // `provisioner: 'cloudformation'`) are authoritative for every CDK-managed harness — they + // are re-parsed in full each deploy, so a CFN harness dropped from the spec correctly + // disappears here (CloudFormation deletes the resource). On top of that, carry forward any + // existing *orphan* record (imperative-build harness, no marker) that the current outputs + // don't cover, so it stays visible to detection/cleanup instead of silently vanishing. + // Only orphans are preserved — carrying forward stale marked records would resurrect a + // harness CloudFormation just deleted. + const existingHarnesses = existingState?.targets?.[targetName]?.resources?.harnesses ?? {}; + const carriedOrphans: Record = {}; + for (const [name, record] of Object.entries(existingHarnesses)) { + if (!harnesses?.[name] && record.provisioner !== 'cloudformation') { + carriedOrphans[name] = record; + } } - - // Add harness state if harnesses exist - if (harnesses && Object.keys(harnesses).length > 0) { - targetState.resources!.harnesses = harnesses; + const mergedHarnesses = { ...carriedOrphans, ...harnesses }; + if (Object.keys(mergedHarnesses).length > 0) { + targetState.resources!.harnesses = mergedHarnesses; } // Add payment state from CFN outputs (or preserve credential provider state) diff --git a/src/cli/commands/abtest/command.ts b/src/cli/commands/abtest/command.ts deleted file mode 100644 index cc236cdb3..000000000 --- a/src/cli/commands/abtest/command.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * AB Test commands. - * - * `agentcore ab-test ` — fetches and displays full AB test details - * from the data plane API, including evaluation scores/metrics. - */ -import { ConfigIO } from '../../../lib'; -import { getABTest, listABTests } from '../../aws/agentcore-ab-tests'; -import type { GetABTestResult } from '../../aws/agentcore-ab-tests'; -import { dnsSuffix } from '../../aws/partition'; -import { getErrorMessage } from '../../errors'; -import type { Command } from '@commander-js/extra-typings'; - -// ============================================================================ -// Helpers -// ============================================================================ - -async function getRegion(cliRegion?: string): Promise { - if (cliRegion) return cliRegion; - try { - const configIO = new ConfigIO(); - const targets = await configIO.resolveAWSDeploymentTargets(); - if (targets.length > 0) return targets[0]!.region; - } catch { - // Fall through to env vars - } - return process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; -} - -async function resolveABTestId( - testName: string, - region: string -): Promise<{ abTestId: string; region: string; error?: string }> { - let projectName: string | undefined; - try { - const configIO = new ConfigIO(); - const deployedState = await configIO.readDeployedState(); - const awsTargets = await configIO.readAWSDeploymentTargets(); - - try { - const projectSpec = await configIO.readProjectSpec(); - projectName = projectSpec.name; - } catch { - // Project spec unavailable - } - - for (const [targetName, target] of Object.entries(deployedState.targets ?? {})) { - const abTests = target.resources?.abTests; - if (abTests?.[testName]) { - const targetConfig = awsTargets.find(t => t.name === targetName); - const resolvedRegion = targetConfig?.region ?? region; - return { abTestId: abTests[testName].abTestId, region: resolvedRegion }; - } - } - } catch { - // No deployed state available - } - - try { - const result = await listABTests({ region, maxResults: 100 }); - // Match against both prefixed name ({projectName}_{testName}) and bare testName (backwards compat) - const prefixedName = projectName ? `${projectName}_${testName}` : undefined; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- boolean OR, not nullish coalescing - const match = result.abTests.find(t => (prefixedName && t.name === prefixedName) || t.name === testName); - if (match) { - return { abTestId: match.abTestId, region }; - } - } catch { - // API call failed - } - - return { abTestId: '', region, error: `AB test "${testName}" not found in deployed state or API.` }; -} - -function gatewayUrlFromArn(arn: string): string { - const parts = arn.split(':'); - const region = parts[3]; - const gatewayId = parts[5]?.split('/')[1]; - if (region && gatewayId) { - return `https://${gatewayId}.gateway.bedrock-agentcore.${region}.${dnsSuffix(region)}`; - } - return arn; -} - -function formatABTestDetails(test: GetABTestResult): string { - const lines: string[] = []; - lines.push(`AB Test: ${test.name}`); - lines.push(` Status: ${test.status}`); - lines.push(` Execution: ${test.executionStatus}`); - lines.push(` Invocation URL: ${gatewayUrlFromArn(test.gatewayArn)}//invocations`); - lines.push( - ` Online Eval: ${'onlineEvaluationConfigArn' in test.evaluationConfig ? test.evaluationConfig.onlineEvaluationConfigArn : 'per-variant'}` - ); - if (test.description) lines.push(` Description: ${test.description}`); - - for (const variant of test.variants) { - const bundleRef = variant.variantConfiguration.configurationBundle; - const targetRef = variant.variantConfiguration.target; - if (targetRef) { - lines.push(` Variant ${variant.name}: weight=${variant.weight}, target=${targetRef.name}`); - } else if (bundleRef) { - lines.push( - ` Variant ${variant.name}: weight=${variant.weight}, bundle=${bundleRef.bundleArn}, version=${bundleRef.bundleVersion}` - ); - } - } - - // TODO(post-preview): Re-enable max duration display once configurable duration is launched. - // if (test.maxDurationDays) lines.push(` Max Duration: ${test.maxDurationDays} days`); - if (test.startedAt) lines.push(` Started: ${test.startedAt}`); - if (test.stoppedAt) lines.push(` Stopped: ${test.stoppedAt}`); - if (test.failureReason) lines.push(` Failure: ${test.failureReason}`); - - if (test.results) { - lines.push(' Results:'); - if (test.results.analysisTimestamp) { - lines.push(` Analysis Time: ${test.results.analysisTimestamp}`); - } - for (const metric of test.results.evaluatorMetrics) { - lines.push(` Evaluator: ${metric.evaluatorArn}`); - lines.push( - ` Control: samples=${metric.controlStats.sampleSize}, mean=${metric.controlStats.mean.toFixed(4)}` - ); - for (const vr of metric.variantResults) { - lines.push( - ` ${vr.treatmentName}: samples=${vr.sampleSize}, mean=${vr.mean.toFixed(4)}, significant=${vr.isSignificant}` - ); - if (vr.absoluteChange !== undefined) - lines.push(` Change: ${vr.absoluteChange.toFixed(4)} (${(vr.percentChange ?? 0).toFixed(2)}%)`); - if (vr.pValue !== undefined) lines.push(` p-value: ${vr.pValue.toFixed(6)}`); - if (vr.confidenceInterval) { - lines.push( - ` CI: [${vr.confidenceInterval.lower?.toFixed(4)}, ${vr.confidenceInterval.upper?.toFixed(4)}]` - ); - } - } - } - } - - return lines.join('\n'); -} - -// ============================================================================ -// Command registration -// ============================================================================ - -export function registerABTestCommand(program: Command): void { - program - .command('ab-test') - .description('[preview] View A/B test details and results') - .argument('', 'AB test name') - .option('--region ', 'AWS region') - .option('--json', 'Output as JSON') - .action(async (name: string, cliOptions: { region?: string; json?: boolean }) => { - try { - const region = await getRegion(cliOptions.region); - const { abTestId, error } = await resolveABTestId(name, region); - if (error) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error })); - } else { - console.error(error); - } - process.exit(1); - } - const result = await getABTest({ region, abTestId }); - - if (cliOptions.json) { - console.log(JSON.stringify(result)); - process.exit(0); - } else if (process.stdout.isTTY) { - // Render TUI detail screen with key bindings - const [{ render }, { default: React }, { ABTestDetailScreen }] = await Promise.all([ - import('ink'), - import('react'), - import('../../tui/screens/ab-test'), - ]); - render( - React.createElement(ABTestDetailScreen, { - abTestId, - region, - onExit: () => process.exit(0), - }) - ); - return; - } else { - console.log(formatABTestDetails(result)); - process.exit(0); - } - } catch (error) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); - } else { - console.error(`Error: ${getErrorMessage(error)}`); - } - process.exit(1); - } - }); -} diff --git a/src/cli/commands/abtest/index.ts b/src/cli/commands/abtest/index.ts deleted file mode 100644 index 0ff25efc5..000000000 --- a/src/cli/commands/abtest/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerABTestCommand } from './command'; diff --git a/src/cli/commands/add/__tests__/add-gateway-target.test.ts b/src/cli/commands/add/__tests__/add-gateway-target.test.ts index e06bc0d83..828908868 100644 --- a/src/cli/commands/add/__tests__/add-gateway-target.test.ts +++ b/src/cli/commands/add/__tests__/add-gateway-target.test.ts @@ -22,8 +22,11 @@ describe('add gateway-target command', () => { } projectDir = join(testDir, projectName); - // Create gateway for tests - const gwResult = await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], projectDir); + // Create gateway for tests with MCP protocol (required for mcpServer and lambdaFunctionArn targets) + const gwResult = await runCLI( + ['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], + projectDir + ); if (gwResult.exitCode !== 0) { throw new Error(`Failed to create gateway: ${gwResult.stdout} ${gwResult.stderr}`); } diff --git a/src/cli/commands/add/__tests__/add-knowledge-base.test.ts b/src/cli/commands/add/__tests__/add-knowledge-base.test.ts new file mode 100644 index 000000000..a4f28a699 --- /dev/null +++ b/src/cli/commands/add/__tests__/add-knowledge-base.test.ts @@ -0,0 +1,106 @@ +import { readProjectConfig, runCLI } from '../../../../test-utils/index.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +/** + * End-to-end coverage for the `--data-source-type` flag registered on + * `add knowledge-base`. Drives the built CLI so we exercise the actual + * commander registration (flag default, --connector-config threading) rather + * than calling add() directly — that path is unit-tested elsewhere. + */ +// FMKB is gated behind ENABLE_GATED_FEATURES; pass it on every runCLI for these tests. +const GATED_ENV = { ENABLE_GATED_FEATURES: '1' }; + +describe('add knowledge-base command — --data-source-type flag', () => { + let testDir: string; + let projectDir: string; + + beforeAll(async () => { + testDir = join(tmpdir(), `agentcore-add-kb-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + const projectName = 'TestProj'; + const result = await runCLI(['create', '--name', projectName, '--no-agent'], testDir); + if (result.exitCode !== 0) { + throw new Error(`Failed to create project: ${result.stdout} ${result.stderr}`); + } + projectDir = join(testDir, projectName); + }); + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('defaults to S3 when --data-source-type is omitted', async () => { + const result = await runCLI( + ['add', 'knowledge-base', '--name', 'kb-default', '--source', 's3://my-bucket/data', '--json'], + projectDir, + { env: GATED_ENV } + ); + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const project = await readProjectConfig(projectDir); + const kb = project.knowledgeBases.find(k => k.name === 'kb-default'); + expect(kb).toBeDefined(); + expect(kb!.dataSources).toEqual([{ type: 'S3', uri: 's3://my-bucket/data' }]); + }); + + it('writes a connector data source when --data-source-type web-crawler is given', async () => { + const cfgPath = join(testDir, 'web-crawler.json'); + await writeFile( + cfgPath, + JSON.stringify({ + type: 'WEB', + connectionConfiguration: { authType: 'NO_AUTH' }, + seedUrls: ['https://example.com'], + }), + 'utf-8' + ); + + const result = await runCLI( + [ + 'add', + 'knowledge-base', + '--name', + 'kb-web', + '--data-source-type', + 'web-crawler', + '--connector-config', + cfgPath, + '--json', + ], + projectDir, + { env: GATED_ENV } + ); + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const project = await readProjectConfig(projectDir); + const kb = project.knowledgeBases.find(k => k.name === 'kb-web'); + expect(kb).toBeDefined(); + expect(kb!.dataSources).toHaveLength(1); + const ds = kb!.dataSources[0]!; + expect(ds.type).toBe('WEB'); + // Connector configs are copied into app//. + expect((ds as { connectorConfigFile?: string }).connectorConfigFile).toBe('app/kb-web/web-crawler.json'); + }); + + it('rejects --connector-config for the default S3 type', async () => { + const cfgPath = join(testDir, 'stray.json'); + await writeFile(cfgPath, JSON.stringify({ type: 'WEB' }), 'utf-8'); + + const result = await runCLI( + ['add', 'knowledge-base', '--name', 'kb-bad', '--connector-config', cfgPath, '--json'], + projectDir, + { env: GATED_ENV } + ); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + }); +}); diff --git a/src/cli/commands/add/__tests__/auth-options.test.ts b/src/cli/commands/add/__tests__/auth-options.test.ts index dd702c11e..18f3ca78a 100644 --- a/src/cli/commands/add/__tests__/auth-options.test.ts +++ b/src/cli/commands/add/__tests__/auth-options.test.ts @@ -115,4 +115,164 @@ describe('validateJwtAuthorizerOptions', () => { valid: true, }); }); + + describe('PrivateLink inbound flags', () => { + it('accepts a lattice resource-config id', () => { + expect( + validateJwtAuthorizerOptions({ ...validBase, privateEndpointLatticeArn: 'rcfg-0123456789abcdefg' }) + ).toEqual({ valid: true }); + }); + + it('accepts a full managed-VPC endpoint', () => { + expect( + validateJwtAuthorizerOptions({ + ...validBase, + privateEndpointVpcId: 'vpc-0123456789abcdef0', + privateEndpointSubnets: 'subnet-0123456789abcdef0, subnet-0fedcba9876543210', + privateEndpointIpType: 'IPV4', + privateEndpointSecurityGroups: 'sg-0123456789abcdef0', + privateEndpointRoutingDomain: 'example.internal', + }) + ).toEqual({ valid: true }); + }); + + it('rejects both lattice + vpc arms (mutually exclusive)', () => { + const result = validateJwtAuthorizerOptions({ + ...validBase, + privateEndpointLatticeArn: 'rcfg-0123456789abcdefg', + privateEndpointVpcId: 'vpc-0123456789abcdef0', + }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('mutually exclusive'); + }); + + it('rejects managed-VPC missing subnets', () => { + const result = validateJwtAuthorizerOptions({ + ...validBase, + privateEndpointVpcId: 'vpc-0123456789abcdef0', + privateEndpointIpType: 'IPV4', + }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('--private-endpoint-subnets is required'); + }); + + it('rejects managed-VPC missing ip-type', () => { + const result = validateJwtAuthorizerOptions({ + ...validBase, + privateEndpointVpcId: 'vpc-0123456789abcdef0', + privateEndpointSubnets: 'subnet-0123456789abcdef0', + }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('--private-endpoint-ip-type'); + }); + + it('rejects an invalid lattice id', () => { + const result = validateJwtAuthorizerOptions({ ...validBase, privateEndpointLatticeArn: 'nope' }); + expect(result.valid).toBe(false); + }); + + it('rejects VPC sub-flags without --private-endpoint-vpc-id', () => { + const result = validateJwtAuthorizerOptions({ ...validBase, privateEndpointSubnets: 'subnet-0123456789abcdef0' }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('require --private-endpoint-vpc-id'); + }); + + it('rejects more than 5 overrides', () => { + const overrides = JSON.stringify( + Array.from({ length: 6 }, (_, i) => ({ + domain: `d${i}.example.com`, + privateEndpoint: { + selfManagedLatticeResource: { resourceConfigurationIdentifier: 'rcfg-0123456789abcdefg' }, + }, + })) + ); + const result = validateJwtAuthorizerOptions({ ...validBase, privateEndpointOverrides: overrides }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('at most 5'); + }); + + it('rejects malformed overrides JSON', () => { + const result = validateJwtAuthorizerOptions({ ...validBase, privateEndpointOverrides: '{not json' }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('must be valid JSON'); + }); + + it('accepts valid overrides (with a matching base lattice endpoint)', () => { + const overrides = JSON.stringify([ + { + domain: 'api.example.com', + privateEndpoint: { + selfManagedLatticeResource: { resourceConfigurationIdentifier: 'rcfg-0123456789abcdefg' }, + }, + }, + ]); + expect( + validateJwtAuthorizerOptions({ + ...validBase, + privateEndpointLatticeArn: 'rcfg-0123456789abcdefg', + privateEndpointOverrides: overrides, + }) + ).toEqual({ valid: true }); + }); + + it('rejects overrides without a base private endpoint', () => { + const overrides = JSON.stringify([ + { + domain: 'api.example.com', + privateEndpoint: { + selfManagedLatticeResource: { resourceConfigurationIdentifier: 'rcfg-0123456789abcdefg' }, + }, + }, + ]); + const result = validateJwtAuthorizerOptions({ ...validBase, privateEndpointOverrides: overrides }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('requires a base private endpoint'); + }); + + it('rejects an override arm that mismatches the base arm (lattice base, vpc override)', () => { + const overrides = JSON.stringify([ + { + domain: 'api.example.com', + privateEndpoint: { + managedVpcResource: { + vpcIdentifier: 'vpc-0123456789abcdef0', + subnetIds: ['subnet-0123456789abcdef0'], + endpointIpAddressType: 'IPV4', + }, + }, + }, + ]); + const result = validateJwtAuthorizerOptions({ + ...validBase, + privateEndpointLatticeArn: 'rcfg-0123456789abcdefg', + privateEndpointOverrides: overrides, + }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('same kind as the base endpoint'); + }); + + it('rejects duplicate override domains', () => { + const overrides = JSON.stringify([ + { + domain: 'dup.example.com', + privateEndpoint: { + selfManagedLatticeResource: { resourceConfigurationIdentifier: 'rcfg-0123456789abcdefg' }, + }, + }, + { + domain: 'dup.example.com', + privateEndpoint: { + selfManagedLatticeResource: { resourceConfigurationIdentifier: 'rcfg-0123456789abcdefg' }, + }, + }, + ]); + const result = validateJwtAuthorizerOptions({ + ...validBase, + privateEndpointLatticeArn: 'rcfg-0123456789abcdefg', + privateEndpointOverrides: overrides, + }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('Duplicate private-endpoint override domain'); + }); + }); }); diff --git a/src/cli/commands/add/__tests__/harness-privatelink-guard.test.ts b/src/cli/commands/add/__tests__/harness-privatelink-guard.test.ts new file mode 100644 index 000000000..3795b3b09 --- /dev/null +++ b/src/cli/commands/add/__tests__/harness-privatelink-guard.test.ts @@ -0,0 +1,191 @@ +import type { AddHarnessCliOptions } from '../types'; +import { validateAddHarnessOptions } from '../validate'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const base: AddHarnessCliOptions = { + name: 'h1', + modelProvider: 'bedrock', + modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', +}; + +const DISCOVERY = 'https://idp.example.com/.well-known/openid-configuration'; + +describe('validateAddHarnessOptions — PrivateLink authorizer guard', () => { + it('rejects --private-endpoint-* flags with AWS_IAM authorizer', () => { + const result = validateAddHarnessOptions({ + ...base, + authorizerType: 'AWS_IAM', + privateEndpointVpcId: 'vpc-0123456789abcdef0', + privateEndpointSubnets: 'subnet-0123456789abcdef0', + privateEndpointIpType: 'IPV4', + }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('only valid with CUSTOM_JWT'); + }); + + it('rejects a private-endpoint flag when no authorizer type is set', () => { + const result = validateAddHarnessOptions({ ...base, privateEndpointLatticeArn: 'rcfg-0123456789abcdefg' }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('only valid with CUSTOM_JWT'); + }); + + it('accepts a private-endpoint flag with CUSTOM_JWT authorizer', () => { + const result = validateAddHarnessOptions({ + ...base, + authorizerType: 'CUSTOM_JWT', + discoveryUrl: DISCOVERY, + allowedAudience: 'aud-1', + privateEndpointLatticeArn: 'rcfg-0123456789abcdefg', + }); + expect(result.valid).toBe(true); + }); + + it('does not flag a plain AWS_IAM harness (no PrivateLink flags)', () => { + const result = validateAddHarnessOptions({ ...base, authorizerType: 'AWS_IAM' }); + expect(result.valid).toBe(true); + }); +}); + +describe('validateAddHarnessOptions — memory flag coupling', () => { + it('rejects --memory-arn together with --memory-name (mutually exclusive)', () => { + const result = validateAddHarnessOptions({ + ...base, + memoryArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:memory/Mem-aBcDeFgHiJ', + memoryName: 'mem', + }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('mutually exclusive'); + }); + + it('rejects --no-memory combined with --memory-arn', () => { + const result = validateAddHarnessOptions({ + ...base, + memory: false, + memoryArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:memory/Mem-aBcDeFgHiJ', + }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('--no-memory'); + }); + + it('rejects --no-memory combined with a memory tuning flag', () => { + const result = validateAddHarnessOptions({ ...base, memory: false, memoryTopK: 5 }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('--no-memory'); + }); + + it('accepts --memory-name alone', () => { + expect(validateAddHarnessOptions({ ...base, memoryName: 'mem' }).valid).toBe(true); + }); +}); + +describe('validateAddHarnessOptions — gateway oauth flag coupling', () => { + it('rejects --gateway-grant-type without --gateway-outbound-auth oauth', () => { + const result = validateAddHarnessOptions({ + ...base, + tools: 'agentcore_gateway', + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gw', + gatewayOutboundAuth: 'awsIam', + gatewayGrantType: 'CLIENT_CREDENTIALS', + }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('--gateway-outbound-auth oauth'); + }); + + it('rejects --gateway-custom-parameters when outbound-auth is absent', () => { + const result = validateAddHarnessOptions({ + ...base, + tools: 'agentcore_gateway', + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gw', + gatewayCustomParameters: '{"audience":"x"}', + }); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.error).toContain('--gateway-outbound-auth oauth'); + }); +}); + +describe('validateAddHarnessOptions — memory modes (gated OFF)', () => { + const prev = process.env.ENABLE_GATED_FEATURES; + beforeEach(() => { + delete process.env.ENABLE_GATED_FEATURES; + }); + afterEach(() => { + if (prev === undefined) delete process.env.ENABLE_GATED_FEATURES; + else process.env.ENABLE_GATED_FEATURES = prev; + }); + + it('rejects --memory-mode as not-yet-available', () => { + const r = validateAddHarnessOptions({ ...base, memoryMode: 'managed' }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain('not yet available'); + }); + + it('rejects managed-only flags as not-yet-available', () => { + const r = validateAddHarnessOptions({ ...base, memoryStrategies: 'SEMANTIC' }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain('not yet available'); + }); + + it('still accepts the legacy --memory-name reference', () => { + expect(validateAddHarnessOptions({ ...base, memoryName: 'mem' }).valid).toBe(true); + }); +}); + +describe('validateAddHarnessOptions — memory modes (gated ON)', () => { + const prev = process.env.ENABLE_GATED_FEATURES; + beforeEach(() => { + process.env.ENABLE_GATED_FEATURES = '1'; + }); + afterEach(() => { + if (prev === undefined) delete process.env.ENABLE_GATED_FEATURES; + else process.env.ENABLE_GATED_FEATURES = prev; + }); + + it('rejects --memory-mode existing with neither arn nor name', () => { + const r = validateAddHarnessOptions({ ...base, memoryMode: 'existing' }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain('existing'); + }); + + it('rejects managed-only flags on existing mode', () => { + const r = validateAddHarnessOptions({ + ...base, + memoryMode: 'existing', + memoryArn: 'arn:aws:bedrock-agentcore:us-west-2:1:memory/m-aBcD012345', + memoryEventExpiryDays: 30, + }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain('--memory-event-expiry-days'); + }); + + it('rejects an invalid managed strategy', () => { + const r = validateAddHarnessOptions({ ...base, memoryMode: 'managed', memoryStrategies: 'SEMANTIC,BOGUS' }); + expect(r.valid).toBe(false); + }); + + it('rejects CUSTOM as a managed strategy', () => { + const r = validateAddHarnessOptions({ ...base, memoryMode: 'managed', memoryStrategies: 'CUSTOM' }); + expect(r.valid).toBe(false); + }); + + it('rejects an invalid --memory-mode value', () => { + const r = validateAddHarnessOptions({ ...base, memoryMode: 'bogus' }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain('Invalid --memory-mode'); + }); + + it('rejects --no-memory combined with --memory-mode managed', () => { + const r = validateAddHarnessOptions({ ...base, memory: false, memoryMode: 'managed' }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain('--no-memory'); + }); + + it('accepts a clean managed config', () => { + expect( + validateAddHarnessOptions({ ...base, memoryMode: 'managed', memoryStrategies: 'SEMANTIC,SUMMARIZATION' }).valid + ).toBe(true); + }); + + it('accepts managed as the implicit default (no memory flags)', () => { + expect(validateAddHarnessOptions({ ...base }).valid).toBe(true); + }); +}); diff --git a/src/cli/commands/add/__tests__/skill-action.test.ts b/src/cli/commands/add/__tests__/skill-action.test.ts new file mode 100644 index 000000000..3aaaa2d4c --- /dev/null +++ b/src/cli/commands/add/__tests__/skill-action.test.ts @@ -0,0 +1,209 @@ +import type { HarnessSpec } from '../../../../schema'; +import { handleAddSkill } from '../skill-action.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockReadHarnessSpec = vi.fn(); +const mockWriteHarnessSpec = vi.fn(); +const mockReadProjectSpec = vi.fn(); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readHarnessSpec = mockReadHarnessSpec; + writeHarnessSpec = mockWriteHarnessSpec; + readProjectSpec = mockReadProjectSpec; + }, +})); + +function makeHarnessSpec(overrides: Partial = {}): HarnessSpec { + return { + name: 'TestHarness', + model: { provider: 'bedrock', modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0' }, + tools: [], + skills: [], + ...overrides, + } as HarnessSpec; +} + +describe('handleAddSkill', () => { + beforeEach(() => { + mockReadHarnessSpec.mockReset(); + mockWriteHarnessSpec.mockReset(); + mockReadProjectSpec.mockReset(); + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'my-git-cred', authorizerType: 'ApiKeyCredentialProvider' }], + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('adds a path skill to harness', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec()); + const result = await handleAddSkill({ harness: 'TestHarness', path: './my-skill' }); + expect(result.success).toBe(true); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'TestHarness', + expect.objectContaining({ + skills: [{ path: './my-skill' }], + }) + ); + }); + + it('adds an S3 skill to harness', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec()); + const result = await handleAddSkill({ harness: 'TestHarness', s3: 's3://bucket/skill' }); + expect(result.success).toBe(true); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'TestHarness', + expect.objectContaining({ + skills: [{ s3Uri: 's3://bucket/skill' }], + }) + ); + }); + + it('adds a git skill to harness', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec()); + const result = await handleAddSkill({ + harness: 'TestHarness', + git: 'https://github.com/org/repo', + gitPath: 'skills/foo', + }); + expect(result.success).toBe(true); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'TestHarness', + expect.objectContaining({ + skills: [{ gitUrl: 'https://github.com/org/repo', path: 'skills/foo' }], + }) + ); + }); + + it('adds a git skill with auth', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec()); + const result = await handleAddSkill({ + harness: 'TestHarness', + git: 'https://github.com/org/repo', + credentialName: 'my-git-cred', + username: 'bot', + }); + expect(result.success).toBe(true); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'TestHarness', + expect.objectContaining({ + skills: [{ gitUrl: 'https://github.com/org/repo', auth: { credentialName: 'my-git-cred', username: 'bot' } }], + }) + ); + }); + + it('fails when no source type provided', async () => { + const result = await handleAddSkill({ harness: 'TestHarness' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('Exactly one'); + }); + + it('fails when multiple source types provided', async () => { + const result = await handleAddSkill({ harness: 'TestHarness', path: './x', s3: 's3://y' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('Exactly one'); + }); + + it('fails when harness not found', async () => { + mockReadHarnessSpec.mockRejectedValue(new Error('not found')); + const result = await handleAddSkill({ harness: 'Missing', path: './x' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('not found'); + }); + + it('fails when s3 URI does not start with s3://', async () => { + const result = await handleAddSkill({ harness: 'TestHarness', s3: 'bucket/skill' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('s3://'); + }); + + it('fails when git URL does not start with https://', async () => { + const result = await handleAddSkill({ harness: 'TestHarness', git: 'git@github.com:org/repo.git' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('https://'); + }); + + it('rejects duplicate path skill', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec({ skills: [{ path: './my-skill' }] })); + const result = await handleAddSkill({ harness: 'TestHarness', path: './my-skill' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('already exists'); + }); + + it('rejects duplicate s3 skill', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec({ skills: [{ s3Uri: 's3://bucket/skill' }] })); + const result = await handleAddSkill({ harness: 'TestHarness', s3: 's3://bucket/skill' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('already exists'); + }); + + it('rejects duplicate git skill', async () => { + mockReadHarnessSpec.mockResolvedValue( + makeHarnessSpec({ + skills: [{ gitUrl: 'https://github.com/org/repo' }], + }) + ); + const result = await handleAddSkill({ harness: 'TestHarness', git: 'https://github.com/org/repo' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('already exists'); + }); + + it('rejects --git-path without --git', async () => { + const result = await handleAddSkill({ harness: 'TestHarness', path: './x', gitPath: 'sub' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('--git'); + }); + + it('rejects --credential without --git', async () => { + const result = await handleAddSkill({ harness: 'TestHarness', path: './x', credentialName: 'my-cred' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('--git'); + }); + + it('rejects --username without --git', async () => { + const result = await handleAddSkill({ harness: 'TestHarness', path: './x', username: 'bot' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('--git'); + }); + + it('rejects credential that does not exist in project', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec()); + mockReadProjectSpec.mockResolvedValue({ credentials: [] }); + const result = await handleAddSkill({ + harness: 'TestHarness', + git: 'https://github.com/org/repo', + credentialName: 'nonexistent', + }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('not found in project'); + }); + + it('rejects OAuth credential for git skill auth', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec()); + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'my-oauth-cred', authorizerType: 'OAuthCredentialProvider' }], + }); + const result = await handleAddSkill({ + harness: 'TestHarness', + git: 'https://github.com/org/repo', + credentialName: 'my-oauth-cred', + }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('ApiKeyCredentialProvider'); + }); + + it('returns clean error when project config is unreadable', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec()); + mockReadProjectSpec.mockRejectedValue(new Error('Config file not found')); + const result = await handleAddSkill({ + harness: 'TestHarness', + git: 'https://github.com/org/repo', + credentialName: 'my-cred', + }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('Could not read project configuration'); + }); +}); diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 070801f40..f28997edc 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -190,6 +190,25 @@ describe('validate', () => { expect(result.error?.includes('Strands')).toBeTruthy(); }); + it('rejects Python with the Vercel AI framework (TypeScript-only)', () => { + const result = validateAddAgentOptions({ + ...validAgentOptionsCreate, + language: 'Python', + framework: 'VercelAI', + }); + expect(result.valid).toBe(false); + expect(result.error?.includes('is not yet available for Python')).toBeTruthy(); + }); + + it('accepts TypeScript with the Vercel AI framework', () => { + const result = validateAddAgentOptions({ + ...validAgentOptionsCreate, + language: 'TypeScript', + framework: 'VercelAI', + }); + expect(result.valid).toBe(true); + }); + it('returns error for create path with Other language', () => { const result = validateAddAgentOptions({ ...validAgentOptionsCreate, language: 'Other' }); expect(result.valid).toBe(false); @@ -449,6 +468,43 @@ describe('validate', () => { const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptions }); expect(result.valid).toBe(true); }); + + // Passthrough is gated behind ENABLE_GATED_FEATURES + describe('passthrough feature flag', () => { + const passthroughOpts: AddGatewayTargetOptions = { + name: 'pt-target', + type: 'passthrough', + gateway: 'my-gateway', + passthroughEndpoint: 'https://api.example.com', + } as AddGatewayTargetOptions; + + afterEach(() => { + delete process.env.ENABLE_GATED_FEATURES; + }); + + it('rejects passthrough when the flag is off', async () => { + delete process.env.ENABLE_GATED_FEATURES; + const result = await validateAddGatewayTargetOptions({ ...passthroughOpts }); + expect(result.valid).toBe(false); + expect(result.error).toBe('Passthrough targets are not yet available.'); + }); + + it('omits passthrough from the invalid-type error when the flag is off', async () => { + delete process.env.ENABLE_GATED_FEATURES; + const result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptions, + type: 'bogus-type', + } as AddGatewayTargetOptions); + expect(result.valid).toBe(false); + expect(result.error).not.toContain('passthrough'); + }); + + it('allows passthrough when the flag is on', async () => { + process.env.ENABLE_GATED_FEATURES = '1'; + const result = await validateAddGatewayTargetOptions({ ...passthroughOpts }); + expect(result.valid).toBe(true); + }); + }); // AC20: type validation it('returns error when --type is missing', async () => { const options: AddGatewayTargetOptions = { @@ -940,6 +996,98 @@ describe('validate', () => { expect(result.valid).toBe(false); expect(result.error).toBe('--host is not applicable for MCP server targets'); }); + + // HTTP Runtime target validation + it('accepts valid http-runtime options with --runtime', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-http-target', + type: 'http-runtime', + runtime: 'my-agent', + gateway: 'my-gateway', + }); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects http-runtime without --runtime', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-http-target', + type: 'http-runtime', + gateway: 'my-gateway', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--runtime is required'); + }); + + it('rejects http-runtime with --host', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-http-target', + type: 'http-runtime', + runtime: 'my-agent', + gateway: 'my-gateway', + host: 'Lambda', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('not applicable for http-runtime type'); + }); + + it('rejects http-runtime with --rest-api-id', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-http-target', + type: 'http-runtime', + runtime: 'my-agent', + gateway: 'my-gateway', + restApiId: 'abc123', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('not applicable for http-runtime type'); + }); + + it('rejects http-runtime with --lambda-arn', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-http-target', + type: 'http-runtime', + runtime: 'my-agent', + gateway: 'my-gateway', + lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('not applicable for http-runtime type'); + }); + + it('rejects http-runtime with --tool-schema-file', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-http-target', + type: 'http-runtime', + runtime: 'my-agent', + gateway: 'my-gateway', + toolSchemaFile: './tools.json', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('not applicable for http-runtime type'); + }); + + it('accepts http-runtime with --runtime-endpoint', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-http-target', + type: 'http-runtime', + runtime: 'my-agent', + runtimeEndpoint: 'LIVE', + gateway: 'my-gateway', + }); + expect(result.valid).toBe(true); + }); + + it('sets language to Other for http-runtime type', async () => { + const opts: AddGatewayTargetOptions = { + name: 'my-http-target', + type: 'http-runtime', + runtime: 'my-agent', + gateway: 'my-gateway', + }; + await validateAddGatewayTargetOptions(opts); + expect(opts.language).toBe('Other'); + }); }); describe('validateAddMemoryOptions', () => { diff --git a/src/cli/commands/add/auth-options.ts b/src/cli/commands/add/auth-options.ts index 60cb155d9..07b3e61f4 100644 --- a/src/cli/commands/add/auth-options.ts +++ b/src/cli/commands/add/auth-options.ts @@ -1,4 +1,4 @@ -import { CustomClaimValidationSchema } from '../../../schema'; +import { CustomClaimValidationSchema, PrivateEndpointOverrideSchema, PrivateEndpointSchema } from '../../../schema'; import type { ValidationResult } from './validate'; const OIDC_WELL_KNOWN_SUFFIX = '/.well-known/openid-configuration'; @@ -12,6 +12,15 @@ export interface JwtAuthorizerCliOptions { customClaims?: string; clientId?: string; clientSecret?: string; + // PrivateLink inbound (private endpoint for reaching the OIDC discovery URL). + privateEndpointLatticeArn?: string; + privateEndpointVpcId?: string; + privateEndpointSubnets?: string; + privateEndpointIpType?: string; + privateEndpointSecurityGroups?: string; + privateEndpointRoutingDomain?: string; + privateEndpointTags?: string; + privateEndpointOverrides?: string; } /** @@ -76,5 +85,136 @@ export function validateJwtAuthorizerOptions(options: JwtAuthorizerCliOptions): return { valid: false, error: 'Both --client-id and --client-secret must be provided together' }; } + const privateLinkResult = validatePrivateEndpointOptions(options); + if (!privateLinkResult.valid) return privateLinkResult; + + return { valid: true }; +} + +/** + * Validate PrivateLink inbound flags. The two endpoint arms (lattice / managed-vpc) are mutually + * exclusive; managed-vpc requires --private-endpoint-subnets + --private-endpoint-ip-type. Field + * formats and the ≤5 overrides/SG limits are checked by parsing against the Zod schemas (single + * source of truth) so the CLI and the deploy-time validation never diverge. + */ +function validatePrivateEndpointOptions(options: JwtAuthorizerCliOptions): ValidationResult { + const hasLattice = !!options.privateEndpointLatticeArn?.trim(); + const hasVpc = !!options.privateEndpointVpcId?.trim(); + + if (hasLattice && hasVpc) { + return { + valid: false, + error: + '--private-endpoint-lattice-arn and --private-endpoint-vpc-id are mutually exclusive (a private endpoint is one of VPC Lattice or a managed VPC endpoint)', + }; + } + + // VPC-arm sub-flags require the VPC arm. + const vpcSubFlags = [ + options.privateEndpointSubnets, + options.privateEndpointIpType, + options.privateEndpointSecurityGroups, + options.privateEndpointRoutingDomain, + options.privateEndpointTags, + ]; + if (!hasVpc && vpcSubFlags.some(f => f?.trim())) { + return { + valid: false, + error: '--private-endpoint-* VPC flags require --private-endpoint-vpc-id', + }; + } + + if (hasLattice) { + const result = PrivateEndpointSchema.safeParse({ + selfManagedLatticeResource: { resourceConfigurationIdentifier: options.privateEndpointLatticeArn }, + }); + if (!result.success) { + return { valid: false, error: `Invalid --private-endpoint-lattice-arn: ${result.error.issues[0]?.message}` }; + } + } + + if (hasVpc) { + if (!options.privateEndpointSubnets?.trim()) { + return { valid: false, error: '--private-endpoint-subnets is required with --private-endpoint-vpc-id' }; + } + if (!options.privateEndpointIpType?.trim()) { + return { + valid: false, + error: '--private-endpoint-ip-type (IPV4 or IPV6) is required with --private-endpoint-vpc-id', + }; + } + let tags: unknown; + if (options.privateEndpointTags) { + try { + tags = JSON.parse(options.privateEndpointTags); + } catch { + return { valid: false, error: '--private-endpoint-tags must be valid JSON' }; + } + } + const result = PrivateEndpointSchema.safeParse({ + managedVpcResource: { + vpcIdentifier: options.privateEndpointVpcId, + subnetIds: options.privateEndpointSubnets.split(',').map(s => s.trim()), + endpointIpAddressType: options.privateEndpointIpType, + ...(options.privateEndpointSecurityGroups && { + securityGroupIds: options.privateEndpointSecurityGroups.split(',').map(s => s.trim()), + }), + ...(options.privateEndpointRoutingDomain && { routingDomain: options.privateEndpointRoutingDomain }), + ...(tags !== undefined && { tags }), + }, + }); + if (!result.success) { + return { valid: false, error: `Invalid managed-VPC private endpoint: ${result.error.issues[0]?.message}` }; + } + } + + if (options.privateEndpointOverrides) { + let parsed: unknown; + try { + parsed = JSON.parse(options.privateEndpointOverrides); + } catch { + return { valid: false, error: '--private-endpoint-overrides must be valid JSON' }; + } + if (!Array.isArray(parsed)) { + return { valid: false, error: '--private-endpoint-overrides must be a JSON array' }; + } + if (parsed.length > 5) { + return { valid: false, error: '--private-endpoint-overrides allows at most 5 entries' }; + } + // Coupling rules (mirror the AgentCore Identity service): overrides require a base endpoint, every + // override must use the same arm as the base, and override domains must be unique. + if (!hasLattice && !hasVpc) { + return { + valid: false, + error: + '--private-endpoint-overrides requires a base private endpoint (--private-endpoint-lattice-arn or --private-endpoint-vpc-id)', + }; + } + const baseArm = hasLattice ? 'selfManagedLatticeResource' : 'managedVpcResource'; + const seenDomains = new Set(); + for (const [i, entry] of parsed.entries()) { + const result = PrivateEndpointOverrideSchema.safeParse(entry); + if (!result.success) { + return { + valid: false, + error: `Invalid private-endpoint override at index ${i}: ${result.error.issues[0]?.message}`, + }; + } + const overrideArm = result.data.privateEndpoint.selfManagedLatticeResource + ? 'selfManagedLatticeResource' + : 'managedVpcResource'; + if (overrideArm !== baseArm) { + return { + valid: false, + error: `Private-endpoint override at index ${i} must be the same kind as the base endpoint (all ${baseArm === 'selfManagedLatticeResource' ? 'VPC Lattice' : 'managed VPC'})`, + }; + } + if (seenDomains.has(result.data.domain)) { + return { valid: false, error: `Duplicate private-endpoint override domain: ${result.data.domain}` }; + } + seenDomains.add(result.data.domain); + } + } + return { valid: true }; } diff --git a/src/cli/commands/add/skill-action.ts b/src/cli/commands/add/skill-action.ts new file mode 100644 index 000000000..f87e64130 --- /dev/null +++ b/src/cli/commands/add/skill-action.ts @@ -0,0 +1,140 @@ +import { ConfigIO } from '../../../lib'; +import type { HarnessSpec } from '../../../schema'; +import { isGatedFeaturesEnabled } from '@/cli/feature-flags'; +import { getSkillKey, validateGitSkillCredential } from '@/cli/operations/harness/skill-utils'; +import { ValidationError } from '@/lib/errors/types'; +import type { Result } from '@/lib/result'; + +export interface AddSkillOptions { + harness: string; + path?: string; + s3?: string; + git?: string; + gitPath?: string; + credentialName?: string; + username?: string; + awsSkills?: string | true; +} + +export async function handleAddSkill( + options: AddSkillOptions +): Promise> { + const { harness } = options; + + const gitOnlyFlags = [ + options.gitPath && '--git-path', + options.credentialName && '--credential', + options.username && '--username', + ].filter(Boolean); + + if (gitOnlyFlags.length > 0 && !options.git) { + return { + success: false, + error: new ValidationError(`${gitOnlyFlags.join(', ')} can only be used with --git`), + }; + } + + if (options.awsSkills && !isGatedFeaturesEnabled()) { + return { + success: false, + error: new ValidationError('AWS skills are not yet available.'), + }; + } + + const sources = [options.path, options.s3, options.git, ...(isGatedFeaturesEnabled() ? [options.awsSkills] : [])]; + const sourceCount = sources.filter(Boolean).length; + if (sourceCount !== 1) { + return { + success: false, + error: new ValidationError( + isGatedFeaturesEnabled() + ? 'Exactly one of --path, --s3, --git, or --aws-skills is required' + : 'Exactly one of --path, --s3, or --git is required' + ), + }; + } + + if (options.s3 && !options.s3.startsWith('s3://')) { + return { success: false, error: new ValidationError('--s3 must be an S3 URI starting with s3://') }; + } + + if (options.git && !options.git.startsWith('https://')) { + return { success: false, error: new ValidationError('--git must be an HTTPS URL starting with https://') }; + } + + const configIO = new ConfigIO(); + + let harnessSpec: HarnessSpec; + try { + harnessSpec = await configIO.readHarnessSpec(harness); + } catch { + return { + success: false, + error: new ValidationError( + `Harness '${harness}' not found. Check the name or run 'agentcore add harness' first.` + ), + }; + } + + if (options.credentialName) { + let project; + try { + project = await configIO.readProjectSpec(); + } catch { + return { + success: false, + error: new ValidationError(`Could not read project configuration. Ensure agentcore.json exists and is valid.`), + }; + } + const validation = validateGitSkillCredential(project, options.credentialName); + if (!validation.success) return validation; + } + + let skillEntry: HarnessSpec['skills'][number]; + let skillSource: string; + + if (options.path) { + skillEntry = { path: options.path }; + skillSource = options.path; + } else if (options.s3) { + skillEntry = { s3Uri: options.s3 }; + skillSource = options.s3; + } else if (options.git) { + skillEntry = { + gitUrl: options.git, + ...(options.gitPath && { path: options.gitPath }), + ...(options.credentialName && { + auth: { + credentialName: options.credentialName, + ...(options.username && { username: options.username }), + }, + }), + }; + skillSource = options.gitPath ? `${options.git} (path: ${options.gitPath})` : options.git; + } else { + const paths = + options.awsSkills === true + ? undefined + : options + .awsSkills!.split(',') + .map(s => s.trim()) + .filter(Boolean) + .sort(); + skillEntry = { awsSkills: { ...(paths && { paths }) } }; + skillSource = paths ? `aws-skills (${paths.join(', ')})` : 'aws-skills (all)'; + } + + const newKey = getSkillKey(skillEntry); + const isDuplicate = harnessSpec.skills.some(s => getSkillKey(s) === newKey); + if (isDuplicate) { + return { + success: false, + error: new ValidationError(`Skill '${skillSource}' already exists in harness '${harness}'`), + }; + } + + harnessSpec.skills.push(skillEntry); + await configIO.writeHarnessSpec(harness, harnessSpec); + + return { success: true, harnessName: harness, skillSource }; +} diff --git a/src/cli/commands/add/skill-command.ts b/src/cli/commands/add/skill-command.ts new file mode 100644 index 000000000..6f5e1fc00 --- /dev/null +++ b/src/cli/commands/add/skill-command.ts @@ -0,0 +1,78 @@ +import { findConfigRoot } from '../../../lib'; +import { getErrorMessage } from '../../errors'; +import { isGatedFeaturesEnabled } from '../../feature-flags'; +import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; +import { SkillSourceType, standardize } from '../../telemetry/schemas/common-shapes.js'; +import { handleAddSkill } from './skill-action'; +import { Option } from '@commander-js/extra-typings'; +import type { Command } from '@commander-js/extra-typings'; + +const awsSkillsOption = new Option( + '--aws-skills [paths]', + 'Add built-in AWS skills (comma-separated paths, or omit for all)' +); + +export function registerAddSkill(addCmd: Command): void { + addCmd + .command('skill') + .description('Add a skill to a harness') + .requiredOption('--harness ', 'Target harness name') + .option('--path ', 'Path to an installed skill in the environment') + .option('--s3 ', 'S3 URI (s3://bucket/path)') + .option('--git ', 'HTTPS git repository URL') + .option('--git-path ', 'Subdirectory within the git repo (for --git)') + .option('--credential ', 'Name of an API key credential in the project (for git auth)') + .option('--username ', 'Username for git auth (for --git)') + .addOption(isGatedFeaturesEnabled() ? awsSkillsOption : awsSkillsOption.hideHelp()) + .option('--json', 'Output as JSON') + .action(async cliOptions => { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + try { + const sourceType = cliOptions.awsSkills ? 'aws_skills' : cliOptions.git ? 'git' : cliOptions.s3 ? 's3' : 'path'; + const result = await withCommandRunTelemetry( + 'add.skill', + { skill_source_type: standardize(SkillSourceType, sourceType) }, + () => + handleAddSkill({ + harness: cliOptions.harness, + path: cliOptions.path, + s3: cliOptions.s3, + git: cliOptions.git, + gitPath: cliOptions.gitPath, + credentialName: cliOptions.credential, + username: cliOptions.username, + awsSkills: cliOptions.awsSkills, + }) + ); + + if (!result.success) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: result.error.message })); + } else { + console.error(result.error.message); + } + process.exit(1); + } + + if (cliOptions.json) { + console.log( + JSON.stringify({ success: true, harnessName: result.harnessName, skillSource: result.skillSource }) + ); + } else { + console.log(`Added skill '${result.skillSource}' to harness '${result.harnessName}'.`); + console.log(`Run 'agentcore deploy' to apply changes.`); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(getErrorMessage(error)); + } + process.exit(1); + } + }); +} diff --git a/src/cli/commands/add/tool-action.ts b/src/cli/commands/add/tool-action.ts index cced82d81..57df6f5cb 100644 --- a/src/cli/commands/add/tool-action.ts +++ b/src/cli/commands/add/tool-action.ts @@ -1,6 +1,7 @@ import { ConfigIO } from '../../../lib'; import type { HarnessGatewayOutboundAuth, HarnessSpec } from '../../../schema'; import type { HarnessToolType } from '../../../schema/schemas/primitives/harness'; +import { readFileSync } from 'fs'; export interface AddToolOptions { harness: string; @@ -15,6 +16,10 @@ export interface AddToolOptions { providerArn?: string; scopes?: string; grantType?: string; + /** inline_function: tool description shown to the model. */ + description?: string; + /** inline_function: JSON Schema for the tool input, as a JSON string or @path/to/file.json. */ + inputSchema?: string; json?: boolean; } @@ -57,6 +62,39 @@ export async function handleAddTool(options: AddToolOptions): Promise | undefined; + if (toolType === 'inline_function') { + if (!options.description) { + return { success: false, error: '--description is required for inline_function tools' }; + } + if (!options.inputSchema) { + return { success: false, error: '--input-schema is required for inline_function tools' }; + } + let rawSchema = options.inputSchema; + if (rawSchema.startsWith('@')) { + const path = rawSchema.slice(1); + try { + rawSchema = readFileSync(path, 'utf-8'); + } catch { + return { success: false, error: `Could not read --input-schema file: ${path}` }; + } + } + let parsed: unknown; + try { + parsed = JSON.parse(rawSchema); + } catch { + return { success: false, error: '--input-schema is not valid JSON' }; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { success: false, error: '--input-schema must be a JSON object (a JSON Schema for the tool input)' }; + } + inlineInputSchema = parsed as Record; + } + let outboundAuth: HarnessGatewayOutboundAuth | undefined; if (options.outboundAuth !== undefined) { if (toolType !== 'agentcore_gateway') { @@ -167,6 +205,8 @@ export async function handleAddTool(options: AddToolOptions): Promise', 'OAuth grant type: CLIENT_CREDENTIALS or USER_FEDERATION (for --outbound-auth oauth)' ) + .option('--description ', 'Tool description shown to the model (required for inline_function)') + .option( + '--input-schema ', + 'JSON Schema for the tool input, as a JSON string or @path/to/schema.json (required for inline_function)' + ) .option('--json', 'Output as JSON') .action(async cliOptions => { if (!findConfigRoot()) { @@ -52,6 +57,8 @@ export function registerAddTool(addCmd: Command): void { providerArn: cliOptions.providerArn, scopes: cliOptions.scopes, grantType: cliOptions.grantType, + description: cliOptions.description, + inputSchema: cliOptions.inputSchema, json: cliOptions.json, }); diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 6bb3b95b8..465c4d717 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -50,6 +50,7 @@ export interface AddAgentOptions extends VpcOptions { export interface AddGatewayOptions { name?: string; description?: string; + protocolType?: string; authorizerType?: GatewayAuthorizerType; discoveryUrl?: string; allowedAudience?: string; @@ -75,7 +76,7 @@ export interface AddGatewayTargetOptions { language?: 'Python' | 'TypeScript' | 'Other'; gateway?: string; host?: 'Lambda' | 'AgentCoreRuntime'; - outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE'; + outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE' | 'GATEWAY_IAM_ROLE' | 'JWT_PASSTHROUGH'; credentialName?: string; oauthClientId?: string; oauthClientSecret?: string; @@ -89,6 +90,27 @@ export interface AddGatewayTargetOptions { toolFilterMethods?: string; schema?: string; schemaS3Account?: string; + runtime?: string; + runtimeEndpoint?: string; + /** Connector id (for --type connector): bedrock-knowledge-bases | bedrock-agentic-retrieve | web-search. */ + connector?: string; + /** + * KB reference for --type connector — either a project KB name (entry in + * knowledgeBases[]) or a literal 10-char external KB ID. Repeatable when + * --connector is bedrock-agentic-retrieve (fan-out); single-valued for + * bedrock-knowledge-bases. Not applicable to --connector web-search. + */ + knowledgeBaseId?: string[]; + passthroughEndpoint?: string; + stickinessIdentifier?: string; + stickinessTimeout?: string; + signingService?: string; + signingRegion?: string; + /** + * Comma-separated list of domains to exclude from web search results. + * Only applies to --type web-search. + */ + excludeDomains?: string; json?: boolean; } @@ -120,6 +142,18 @@ export interface AddHarnessCliOptions { gatewayOutboundAuth?: string; gatewayProviderArn?: string; gatewayScopes?: string; + gatewayGrantType?: string; + gatewayCustomParameters?: string; + memoryMode?: string; + memoryStrategies?: string; + memoryEventExpiryDays?: number; + memoryEncryptionKeyArn?: string; + memoryName?: string; + memoryArn?: string; + memoryActorId?: string; + memoryMessagesCount?: number; + memoryTopK?: number; + memoryRelevanceScore?: number; authorizerType?: RuntimeAuthorizerType; discoveryUrl?: string; allowedAudience?: string; @@ -128,6 +162,14 @@ export interface AddHarnessCliOptions { customClaims?: string; clientId?: string; clientSecret?: string; + privateEndpointLatticeArn?: string; + privateEndpointVpcId?: string; + privateEndpointSubnets?: string; + privateEndpointIpType?: string; + privateEndpointSecurityGroups?: string; + privateEndpointRoutingDomain?: string; + privateEndpointTags?: string; + privateEndpointOverrides?: string; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 8b509b6e5..59c0d324b 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -2,6 +2,7 @@ import { ConfigIO, findConfigRoot } from '../../../lib'; import { AgentNameSchema, BuildTypeSchema, + CONNECTOR_ID_VALUES, DatasetNameSchema, DatasetSchemaTypeSchema, GatewayAuthorizerTypeSchema, @@ -15,12 +16,15 @@ import { StreamDeliveryResourcesSchema, TARGET_TYPE_AUTH_CONFIG, TargetLanguageSchema, + getFrameworksForLanguage, getSupportedFrameworksForProtocol, getSupportedModelProviders, + isFrameworkSupportedForLanguage, isValidKmsKeyArn, matchEnumValue, validateApiFormat, } from '../../../schema'; +import { isGatedFeaturesEnabled } from '../../feature-flags'; import { ARN_VALIDATION_MESSAGE, isValidArn } from '../shared/arn-utils'; import { validateHeaderAllowlist } from '../shared/header-utils'; import { MAX_INDEXED_KEYS, parseIndexedKeyArg } from '../shared/indexed-key-parser'; @@ -259,15 +263,16 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes if (options.language === 'Other') { return { valid: false, error: 'Create path only supports Python or TypeScript' }; } + // Framework must ship a template for the chosen language (e.g. Vercel AI is + // TypeScript-only, the other open-source frameworks are Python-only). if ( - options.language === 'TypeScript' && - options.framework && - options.framework !== 'Strands' && - options.framework !== 'VercelAI' + (langResult.data === 'Python' || langResult.data === 'TypeScript') && + !isFrameworkSupportedForLanguage(langResult.data, fwResult.data) ) { + const supported = getFrameworksForLanguage(langResult.data).join(', '); return { valid: false, - error: `Framework ${options.framework} is not yet available for TypeScript. Only Strands and Vercel AI SDK are supported.`, + error: `Framework ${options.framework} is not yet available for ${langResult.data}. Supported: ${supported}.`, }; } @@ -394,11 +399,23 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO return { valid: false, error: '--name is required' }; } + // passthrough is gated; omit it from advertised type lists when the flag is off. + const validTypeList = [ + 'mcp-server', + 'api-gateway', + 'open-api-schema', + 'smithy-model', + 'lambda-function-arn', + 'http-runtime', + 'connector', + ...(isGatedFeaturesEnabled() ? ['passthrough'] : []), + 'web-search', + ].join(', '); + if (!options.type) { return { valid: false, - error: - '--type is required. Valid options: mcp-server, api-gateway, open-api-schema, smithy-model, lambda-function-arn', + error: `--type is required. Valid options: ${validTypeList}`, }; } @@ -408,16 +425,28 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO 'open-api-schema': 'openApiSchema', 'smithy-model': 'smithyModel', 'lambda-function-arn': 'lambdaFunctionArn', + 'http-runtime': 'httpRuntime', + connector: 'connector', + passthrough: 'passthrough', + 'web-search': 'webSearch', }; const mappedType = typeMap[options.type]; if (!mappedType) { return { valid: false, - error: `Invalid type: ${options.type}. Valid options: mcp-server, api-gateway, open-api-schema, smithy-model, lambda-function-arn`, + error: `Invalid type: ${options.type}. Valid options: ${validTypeList}`, }; } options.type = mappedType; + // --exclude-domains is webSearch-target-only. Reject it on every other target type. + if (mappedType !== 'webSearch' && options.excludeDomains) { + return { + valid: false, + error: '--exclude-domains only applies to --type web-search', + }; + } + // Gateway is required — a gateway target must be attached to a gateway if (!options.gateway) { return { @@ -565,6 +594,206 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO return { valid: true }; } + // HTTP Runtime targets: validate early and return + if (mappedType === 'httpRuntime') { + if (!options.runtime) { + return { valid: false, error: '--runtime is required for http-runtime type' }; + } + if (options.language && options.language !== 'Other') { + return { valid: false, error: '--language is not applicable for http-runtime type' }; + } + + const HTTP_RUNTIME_DISALLOWED_OPTIONS = [ + 'host', + 'restApiId', + 'stage', + 'lambdaArn', + 'toolSchemaFile', + 'toolFilterPath', + 'toolFilterMethods', + 'schema', + ] as const; + + for (const opt of HTTP_RUNTIME_DISALLOWED_OPTIONS) { + if (options[opt]) { + return { + valid: false, + error: `--${opt.replace(/([A-Z])/g, '-$1').toLowerCase()} is not applicable for http-runtime type`, + }; + } + } + + // Map --runtime-endpoint to the endpoint field used by createHttpRuntimeTarget + if (options.runtimeEndpoint) { + options.endpoint = options.runtimeEndpoint; + } + + options.language = 'Other'; + return { valid: true }; + } + + // Web search targets (Amazon Web Search managed connector): validate early and return + if (mappedType === 'webSearch') { + const WEB_SEARCH_DISALLOWED_OPTIONS: [string, string][] = [ + ['connector', '--connector'], + ['knowledgeBaseId', '--knowledge-base-id'], + ['endpoint', '--endpoint'], + ['host', '--host'], + ['restApiId', '--rest-api-id'], + ['stage', '--stage'], + ['lambdaArn', '--lambda-arn'], + ['toolSchemaFile', '--tool-schema-file'], + ['toolFilterPath', '--tool-filter-path'], + ['toolFilterMethods', '--tool-filter-methods'], + ['schema', '--schema'], + ['schemaS3Account', '--schema-s3-account'], + ['outboundAuthType', '--outbound-auth'], + ['credentialName', '--credential-name'], + ['oauthClientId', '--oauth-client-id'], + ['oauthClientSecret', '--oauth-client-secret'], + ['oauthDiscoveryUrl', '--oauth-discovery-url'], + ['oauthScopes', '--oauth-scopes'], + ]; + for (const [key, flag] of WEB_SEARCH_DISALLOWED_OPTIONS) { + const v = (options as unknown as Record)[key]; + // knowledgeBaseId is a string[]; treat empty array as absent + const present = Array.isArray(v) ? v.length > 0 : !!v; + if (present) { + return { valid: false, error: `${flag} is not applicable for web-search type` }; + } + } + if (options.language && options.language !== 'Other') { + return { valid: false, error: '--language is not applicable for web-search type' }; + } + options.language = 'Other'; + return { valid: true }; + } + + // Connector targets (Bedrock KB, agentic-retrieve): validate early and return + if (mappedType === 'connector') { + const validConnectors = CONNECTOR_ID_VALUES.join(', '); + if (!options.connector) { + return { + valid: false, + error: `--connector is required for connector type. Valid: ${validConnectors}`, + }; + } + if (!(CONNECTOR_ID_VALUES as readonly string[]).includes(options.connector)) { + return { + valid: false, + error: `Invalid --connector "${options.connector}". Valid: ${validConnectors}`, + }; + } + if (!options.knowledgeBaseId || options.knowledgeBaseId.length === 0) { + return { + valid: false, + error: `--knowledge-base-id is required for --connector ${options.connector}`, + }; + } + if (options.connector === 'bedrock-knowledge-bases' && options.knowledgeBaseId.length > 1) { + return { + valid: false, + error: + '--knowledge-base-id may only be specified once for --connector bedrock-knowledge-bases. Use --connector bedrock-agentic-retrieve for fan-out.', + }; + } + const irrelevant: [string, string][] = [ + ['endpoint', '--endpoint'], + ['host', '--host'], + ['restApiId', '--rest-api-id'], + ['stage', '--stage'], + ['lambdaArn', '--lambda-arn'], + ['toolSchemaFile', '--tool-schema-file'], + ['toolFilterPath', '--tool-filter-path'], + ['toolFilterMethods', '--tool-filter-methods'], + ['outboundAuthType', '--outbound-auth'], + ['credentialName', '--credential-name'], + ['oauthClientId', '--oauth-client-id'], + ['oauthClientSecret', '--oauth-client-secret'], + ['oauthDiscoveryUrl', '--oauth-discovery-url'], + ['oauthScopes', '--oauth-scopes'], + ]; + for (const [key, flag] of irrelevant) { + if ((options as unknown as Record)[key]) { + return { valid: false, error: `${flag} is not applicable for connector type` }; + } + } + if (options.language && options.language !== 'Other') { + return { valid: false, error: '--language is not applicable for connector type' }; + } + options.language = 'Other'; + return { valid: true }; + } + + // Passthrough targets: validate early and return + if (mappedType === 'passthrough') { + if (!isGatedFeaturesEnabled()) { + return { valid: false, error: 'Passthrough targets are not yet available.' }; + } + const passthroughEndpoint = (options as Record).passthroughEndpoint; + if (!passthroughEndpoint) { + return { valid: false, error: '--passthrough-endpoint is required for passthrough type' }; + } + if (!/^https:\/\/[a-zA-Z0-9\-.]+(:[0-9]{1,5})?(\/.*)?$/.test(passthroughEndpoint)) { + return { valid: false, error: '--passthrough-endpoint must be a valid HTTPS URL' }; + } + if (options.language && options.language !== 'Other') { + return { valid: false, error: '--language is not applicable for passthrough type' }; + } + + const PASSTHROUGH_DISALLOWED_OPTIONS = [ + 'host', + 'restApiId', + 'stage', + 'lambdaArn', + 'toolSchemaFile', + 'toolFilterPath', + 'toolFilterMethods', + 'schema', + 'runtime', + 'runtimeEndpoint', + ] as const; + + for (const opt of PASSTHROUGH_DISALLOWED_OPTIONS) { + if (options[opt]) { + return { + valid: false, + error: `--${opt.replace(/([A-Z])/g, '-$1').toLowerCase()} is not applicable for passthrough type`, + }; + } + } + + const stickinessTimeoutRaw = (options as Record).stickinessTimeout; + if (stickinessTimeoutRaw) { + const timeout = parseInt(stickinessTimeoutRaw, 10); + if (isNaN(timeout) || timeout < 1 || timeout > 86400) { + return { valid: false, error: '--stickiness-timeout must be a number between 1 and 86400' }; + } + } + + // Validate outbound auth for passthrough + if (options.outboundAuthType) { + const normalizedAuth = options.outboundAuthType.toUpperCase().replace(/-/g, '_'); + if (normalizedAuth === 'GATEWAY_IAM_ROLE') { + // signingService validation is done in the primitive action handler + } else if (normalizedAuth === 'JWT_PASSTHROUGH') { + // No additional fields required + } else if (normalizedAuth === 'OAUTH') { + if (!options.credentialName) { + const hasInlineOAuth = !!(options.oauthClientId ?? options.oauthClientSecret ?? options.oauthDiscoveryUrl); + if (!hasInlineOAuth) { + return { valid: false, error: '--credential-name or inline OAuth fields required for OAUTH auth' }; + } + } + } else if (normalizedAuth !== 'NONE') { + return { valid: false, error: `Unsupported outbound auth type for passthrough: ${options.outboundAuthType}` }; + } + } + + options.language = 'Other'; + return { valid: true }; + } + // Validate outbound auth configuration if (options.outboundAuthType && options.outboundAuthType !== 'NONE') { const hasInlineOAuth = !!(options.oauthClientId ?? options.oauthClientSecret ?? options.oauthDiscoveryUrl); @@ -892,6 +1121,10 @@ const VALID_HARNESS_TOOLS = [ const VALID_GATEWAY_OUTBOUND_AUTH = ['awsIam', 'none', 'oauth'] as const; +const VALID_MEMORY_MODES = ['managed', 'existing', 'disabled'] as const; +// Managed harness memory excludes CUSTOM (only the four; CUSTOM is for standalone memory). +const VALID_MANAGED_STRATEGIES = ['SEMANTIC', 'SUMMARIZATION', 'USER_PREFERENCE', 'EPISODIC'] as const; + export function validateAddHarnessOptions(options: AddHarnessCliOptions): ValidationResult { if (options.apiFormat) { const provider = options.modelProvider ?? 'bedrock'; @@ -901,6 +1134,13 @@ export function validateAddHarnessOptions(options: AddHarnessCliOptions): Valida } } + // VPC network-mode coupling: reject --subnets/--security-groups when network mode isn't VPC + // (and require them when it is), instead of silently dropping them. Mirrors the agent path. + const vpcResult = validateVpcOptions(options); + if (!vpcResult.valid) { + return vpcResult; + } + if (options.tools) { const toolNames = options.tools.split(',').map(s => s.trim()); for (const tool of toolNames) { @@ -948,6 +1188,84 @@ export function validateAddHarnessOptions(options: AddHarnessCliOptions): Valida } } + // --gateway-grant-type / --gateway-custom-parameters only live on the oauth arm; reject them for + // any other (or absent) outbound-auth rather than silently dropping them at spec-build. + if ( + (options.gatewayGrantType !== undefined || options.gatewayCustomParameters !== undefined) && + options.gatewayOutboundAuth !== 'oauth' + ) { + return { + valid: false, + error: '--gateway-grant-type and --gateway-custom-parameters are only valid with --gateway-outbound-auth oauth', + }; + } + + // Memory flag coupling. Commander sets `memory` to false for --no-memory. + const noMemory = options.memory === false; + const memoryTuningGiven = + options.memoryActorId !== undefined || + options.memoryMessagesCount !== undefined || + options.memoryTopK !== undefined || + options.memoryRelevanceScore !== undefined; + if (options.memoryArn && options.memoryName) { + return { valid: false, error: '--memory-arn and --memory-name are mutually exclusive' }; + } + if (noMemory && (options.memoryArn || options.memoryName || memoryTuningGiven)) { + return { + valid: false, + error: '--no-memory cannot be combined with --memory-arn, --memory-name, or memory tuning flags', + }; + } + + // Managed-memory mode validation — only when the gated feature is enabled. When gated off, any + // --memory-mode / managed-only flag is rejected up front as "not yet available". + const managedOnlyFlags = + options.memoryStrategies !== undefined || + options.memoryEventExpiryDays !== undefined || + options.memoryEncryptionKeyArn !== undefined; + if (!isGatedFeaturesEnabled()) { + if (options.memoryMode !== undefined || managedOnlyFlags) { + return { + valid: false, + error: + '--memory-mode and managed-memory flags (--memory-strategies, --memory-event-expiry-days, --memory-encryption-key-arn) are not yet available.', + }; + } + } else { + if (options.memoryMode && !VALID_MEMORY_MODES.includes(options.memoryMode as (typeof VALID_MEMORY_MODES)[number])) { + return { + valid: false, + error: `Invalid --memory-mode '${options.memoryMode}'. Use ${VALID_MEMORY_MODES.join(', ')}.`, + }; + } + if (noMemory && (options.memoryMode === 'managed' || options.memoryMode === 'existing')) { + return { valid: false, error: '--no-memory cannot be combined with --memory-mode managed/existing' }; + } + if (options.memoryMode === 'existing' && !options.memoryArn && !options.memoryName) { + return { valid: false, error: '--memory-mode existing requires --memory-arn or --memory-name' }; + } + if (managedOnlyFlags && options.memoryMode && options.memoryMode !== 'managed') { + return { + valid: false, + error: + '--memory-strategies, --memory-event-expiry-days, and --memory-encryption-key-arn are only valid with --memory-mode managed', + }; + } + if (options.memoryStrategies) { + const bad = options.memoryStrategies + .split(',') + .map(s => s.trim()) + .filter(Boolean) + .filter(s => !VALID_MANAGED_STRATEGIES.includes(s as (typeof VALID_MANAGED_STRATEGIES)[number])); + if (bad.length) { + return { + valid: false, + error: `Invalid managed memory strateg${bad.length > 1 ? 'ies' : 'y'}: ${bad.join(', ')}. Valid: ${VALID_MANAGED_STRATEGIES.join(', ')}`, + }; + } + } + } + if (options.authorizerType) { const authResult = RuntimeAuthorizerTypeSchema.safeParse(options.authorizerType); if (!authResult.success) { @@ -964,5 +1282,21 @@ export function validateAddHarnessOptions(options: AddHarnessCliOptions): Valida return { valid: false, error: 'OAuth client credentials are only valid with CUSTOM_JWT authorizer' }; } + // PrivateLink (private-endpoint) flags only apply to the CUSTOM_JWT inbound authorizer; reject + // them for any other authorizer rather than silently dropping the config (mirrors the OAuth guard). + const hasPrivateEndpointFlag = [ + options.privateEndpointLatticeArn, + options.privateEndpointVpcId, + options.privateEndpointSubnets, + options.privateEndpointIpType, + options.privateEndpointSecurityGroups, + options.privateEndpointRoutingDomain, + options.privateEndpointTags, + options.privateEndpointOverrides, + ].some(Boolean); + if (hasPrivateEndpointFlag && options.authorizerType !== 'CUSTOM_JWT') { + return { valid: false, error: '--private-endpoint-* flags are only valid with CUSTOM_JWT authorizer' }; + } + return { valid: true }; } diff --git a/src/cli/commands/archive/__tests__/command.test.ts b/src/cli/commands/archive/__tests__/command.test.ts index 8d65c7099..29fa994d6 100644 --- a/src/cli/commands/archive/__tests__/command.test.ts +++ b/src/cli/commands/archive/__tests__/command.test.ts @@ -2,395 +2,112 @@ import { registerArchive } from '../command.js'; import { Command } from '@commander-js/extra-typings'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const mockDeleteBatchEvaluation = vi.fn(); -const mockDeleteRecommendation = vi.fn(); -const mockDeleteLocalBatchEvalRun = vi.fn(); -const mockDeleteLocalRecommendationRun = vi.fn(); +const mockArchive = vi.fn(); const mockRequireProject = vi.fn(); -const mockRender = vi.fn(); -const mockResolveAWSDeploymentTargets = vi.fn(); -vi.mock('../../../aws/agentcore-batch-evaluation', () => ({ - deleteBatchEvaluation: (...args: unknown[]) => mockDeleteBatchEvaluation(...args), -})); - -vi.mock('../../../aws/agentcore-recommendation', () => ({ - deleteRecommendation: (...args: unknown[]) => mockDeleteRecommendation(...args), -})); - -vi.mock('../../../operations/archive/archive-storage', () => ({ - deleteLocalBatchEvalRun: (...args: unknown[]) => mockDeleteLocalBatchEvalRun(...args), - deleteLocalRecommendationRun: (...args: unknown[]) => mockDeleteLocalRecommendationRun(...args), +vi.mock('../../../operations/jobs', () => ({ + createJobEngine: () => ({ archive: (...args: unknown[]) => mockArchive(...args) }), })); vi.mock('../../../tui/guards', () => ({ requireProject: (...args: unknown[]) => mockRequireProject(...args), })); -vi.mock('ink', () => ({ - render: (...args: unknown[]) => mockRender(...args), - Text: 'Text', +// runCliCommand owns process.exit; stub it to run fn() and surface failures as a throw. +vi.mock('../../../telemetry/cli-command-run', () => ({ + runCliCommand: async (_command: string, _json: boolean, fn: () => Promise) => { + await fn(); + }, })); vi.mock('../../../../lib', () => ({ ConfigIO: function () { - return { resolveAWSDeploymentTargets: () => mockResolveAWSDeploymentTargets() }; + return {}; }, })); -const batchEvalResult = { - batchEvaluationId: 'eval-abc-123', - batchEvaluationArn: 'arn:aws:bedrock:us-east-1:123456789:batch-evaluation/eval-abc-123', - status: 'DELETED', -}; - -const recommendationResult = { - recommendationId: 'rec-xyz-789', - status: 'DELETED', -}; - describe('registerArchive', () => { let program: Command; - let mockExit: ReturnType; let mockLog: ReturnType; beforeEach(() => { program = new Command(); program.exitOverride(); registerArchive(program); - - mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit'); - }); mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - mockResolveAWSDeploymentTargets.mockResolvedValue([{ region: 'us-east-1' }]); - mockDeleteLocalBatchEvalRun.mockReturnValue(true); - mockDeleteLocalRecommendationRun.mockReturnValue(true); + mockArchive.mockResolvedValue({ success: true }); }); afterEach(() => { - mockExit.mockRestore(); mockLog.mockRestore(); vi.clearAllMocks(); }); describe('command registration', () => { - it('registers archive command', () => { - const archiveCmd = program.commands.find(c => c.name() === 'archive'); - expect(archiveCmd).toBeDefined(); - }); - - it('registers batch-evaluation subcommand', () => { + it('registers archive with both subcommands', () => { const archiveCmd = program.commands.find(c => c.name() === 'archive')!; - const batchCmd = archiveCmd.commands.find(c => c.name() === 'batch-evaluation'); - expect(batchCmd).toBeDefined(); - }); - - it('registers recommendation subcommand', () => { - const archiveCmd = program.commands.find(c => c.name() === 'archive')!; - const recCmd = archiveCmd.commands.find(c => c.name() === 'recommendation'); - expect(recCmd).toBeDefined(); + expect(archiveCmd).toBeDefined(); + expect(archiveCmd.commands.find(c => c.name() === 'batch-evaluation')).toBeDefined(); + expect(archiveCmd.commands.find(c => c.name() === 'recommendation')).toBeDefined(); }); }); describe('archive batch-evaluation', () => { it('rejects when --id is missing', async () => { await expect(program.parseAsync(['archive', 'batch-evaluation'], { from: 'user' })).rejects.toThrow(); - expect(mockDeleteBatchEvaluation).not.toHaveBeenCalled(); + expect(mockArchive).not.toHaveBeenCalled(); }); - it('calls deleteBatchEvaluation with the given id and auto-detected region', async () => { - mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); - + it('calls engine.archive with the batch-evaluation type and id', async () => { await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); - - expect(mockDeleteBatchEvaluation).toHaveBeenCalledWith({ - region: 'us-east-1', - batchEvaluationId: 'eval-abc-123', - }); - }); - - it('uses --region when provided', async () => { - mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); - - await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--region', 'eu-west-1'], { - from: 'user', - }); - - expect(mockDeleteBatchEvaluation).toHaveBeenCalledWith({ - region: 'eu-west-1', - batchEvaluationId: 'eval-abc-123', - }); + expect(mockArchive).toHaveBeenCalledWith('batch-evaluation', 'eval-abc-123'); }); - it('calls deleteLocalBatchEvalRun with the id', async () => { - mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); - + it('calls requireProject', async () => { await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); - - expect(mockDeleteLocalBatchEvalRun).toHaveBeenCalledWith('eval-abc-123'); - }); - - it('outputs JSON on success with --json flag', async () => { - mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); - - await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }); - - expect(mockLog).toHaveBeenCalledTimes(1); - const output = JSON.parse(mockLog.mock.calls[0]![0]); - expect(output.success).toBe(true); - expect(output.batchEvaluationId).toBe('eval-abc-123'); - expect(output.status).toBe('DELETED'); - expect(output.localCliHistoryDeleted).toBe(true); - }); - - it('includes localCliHistoryDeleted: false in JSON when local file was not found', async () => { - mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); - mockDeleteLocalBatchEvalRun.mockReturnValue(false); - - await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }); - - const output = JSON.parse(mockLog.mock.calls[0]![0]); - expect(output.localCliHistoryDeleted).toBe(false); + expect(mockRequireProject).toHaveBeenCalled(); }); - it('includes localDeleteWarning in JSON and exits 0 when local delete throws', async () => { - mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); - mockDeleteLocalBatchEvalRun.mockImplementation(() => { - throw new Error('Permission denied'); - }); - + it('outputs JSON on success with --json', async () => { await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }); - const output = JSON.parse(mockLog.mock.calls[0]![0]); expect(output.success).toBe(true); - expect(output.localCliHistoryDeleted).toBe(false); - expect(output.localDeleteWarning).toBe('Permission denied'); - expect(mockExit).not.toHaveBeenCalled(); - }); - - it('prints warning and exits 0 when local delete throws without --json', async () => { - mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); - mockDeleteLocalBatchEvalRun.mockImplementation(() => { - throw new Error('Permission denied'); - }); - - await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); - - const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); - expect(allOutput).toContain('Warning: could not clear local history: Permission denied'); - expect(mockExit).not.toHaveBeenCalled(); + expect(output.id).toBe('eval-abc-123'); }); it('prints human-readable success output without --json', async () => { - mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); - await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); - const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); expect(allOutput).toContain('eval-abc-123'); - expect(allOutput).toContain('DELETED'); - }); - - it('does not call process.exit on success', async () => { - mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); - - await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); - - expect(mockExit).not.toHaveBeenCalled(); - }); - - it('outputs JSON error when deleteBatchEvaluation throws and --json is set', async () => { - mockDeleteBatchEvaluation.mockRejectedValue(new Error('Service unavailable')); - - await expect( - program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }) - ).rejects.toThrow('process.exit'); - - const output = JSON.parse(mockLog.mock.calls[0]![0]); - expect(output.success).toBe(false); - expect(output.error).toBe('Service unavailable'); - }); - - it('renders error via ink when deleteBatchEvaluation throws without --json', async () => { - mockDeleteBatchEvaluation.mockRejectedValue(new Error('Service unavailable')); - - await expect( - program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }) - ).rejects.toThrow('process.exit'); - - expect(mockRender).toHaveBeenCalled(); - const renderArg = mockRender.mock.calls[0]![0]; - expect(JSON.stringify(renderArg)).toContain('Service unavailable'); + expect(allOutput).toContain('archived'); }); - it('exits with code 1 on error', async () => { - mockDeleteBatchEvaluation.mockRejectedValue(new Error('fail')); - + it('throws when engine.archive fails', async () => { + mockArchive.mockResolvedValue({ success: false, error: new Error('Service unavailable') }); await expect( program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }) - ).rejects.toThrow('process.exit'); - - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it('calls requireProject', async () => { - mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); - - await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); - - expect(mockRequireProject).toHaveBeenCalled(); + ).rejects.toThrow('Service unavailable'); }); }); describe('archive recommendation', () => { - it('rejects when --id is missing', async () => { - await expect(program.parseAsync(['archive', 'recommendation'], { from: 'user' })).rejects.toThrow(); - expect(mockDeleteRecommendation).not.toHaveBeenCalled(); - }); - - it('calls deleteRecommendation with the given id and auto-detected region', async () => { - mockDeleteRecommendation.mockResolvedValue(recommendationResult); - - await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); - - expect(mockDeleteRecommendation).toHaveBeenCalledWith({ - region: 'us-east-1', - recommendationId: 'rec-xyz-789', - }); - }); - - it('uses --region when provided', async () => { - mockDeleteRecommendation.mockResolvedValue(recommendationResult); - - await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--region', 'ap-southeast-1'], { - from: 'user', - }); - - expect(mockDeleteRecommendation).toHaveBeenCalledWith({ - region: 'ap-southeast-1', - recommendationId: 'rec-xyz-789', - }); - }); - - it('calls deleteLocalRecommendationRun with the id', async () => { - mockDeleteRecommendation.mockResolvedValue(recommendationResult); - + it('calls engine.archive with the recommendation type and id', async () => { await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); - - expect(mockDeleteLocalRecommendationRun).toHaveBeenCalledWith('rec-xyz-789'); + expect(mockArchive).toHaveBeenCalledWith('recommendation', 'rec-xyz-789'); }); - it('outputs JSON on success with --json flag', async () => { - mockDeleteRecommendation.mockResolvedValue(recommendationResult); - - await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }); - - expect(mockLog).toHaveBeenCalledTimes(1); - const output = JSON.parse(mockLog.mock.calls[0]![0]); - expect(output.success).toBe(true); - expect(output.recommendationId).toBe('rec-xyz-789'); - expect(output.status).toBe('DELETED'); - expect(output.localCliHistoryDeleted).toBe(true); - }); - - it('includes localCliHistoryDeleted: false in JSON when local file was not found', async () => { - mockDeleteRecommendation.mockResolvedValue(recommendationResult); - mockDeleteLocalRecommendationRun.mockReturnValue(false); - - await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }); - - const output = JSON.parse(mockLog.mock.calls[0]![0]); - expect(output.localCliHistoryDeleted).toBe(false); - }); - - it('includes localDeleteWarning in JSON and exits 0 when local delete throws', async () => { - mockDeleteRecommendation.mockResolvedValue(recommendationResult); - mockDeleteLocalRecommendationRun.mockImplementation(() => { - throw new Error('Permission denied'); - }); - + it('outputs JSON on success with --json', async () => { await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }); - const output = JSON.parse(mockLog.mock.calls[0]![0]); expect(output.success).toBe(true); - expect(output.localCliHistoryDeleted).toBe(false); - expect(output.localDeleteWarning).toBe('Permission denied'); - expect(mockExit).not.toHaveBeenCalled(); - }); - - it('prints warning and exits 0 when local delete throws without --json', async () => { - mockDeleteRecommendation.mockResolvedValue(recommendationResult); - mockDeleteLocalRecommendationRun.mockImplementation(() => { - throw new Error('Permission denied'); - }); - - await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); - - const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); - expect(allOutput).toContain('Warning: could not clear local history: Permission denied'); - expect(mockExit).not.toHaveBeenCalled(); - }); - - it('prints human-readable success output without --json', async () => { - mockDeleteRecommendation.mockResolvedValue(recommendationResult); - - await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); - - const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); - expect(allOutput).toContain('rec-xyz-789'); - expect(allOutput).toContain('DELETED'); - }); - - it('does not call process.exit on success', async () => { - mockDeleteRecommendation.mockResolvedValue(recommendationResult); - - await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); - - expect(mockExit).not.toHaveBeenCalled(); - }); - - it('outputs JSON error when deleteRecommendation throws and --json is set', async () => { - mockDeleteRecommendation.mockRejectedValue(new Error('Not found')); - - await expect( - program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }) - ).rejects.toThrow('process.exit'); - - const output = JSON.parse(mockLog.mock.calls[0]![0]); - expect(output.success).toBe(false); - expect(output.error).toBe('Not found'); - }); - - it('renders error via ink when deleteRecommendation throws without --json', async () => { - mockDeleteRecommendation.mockRejectedValue(new Error('Not found')); - - await expect( - program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }) - ).rejects.toThrow('process.exit'); - - expect(mockRender).toHaveBeenCalled(); - const renderArg = mockRender.mock.calls[0]![0]; - expect(JSON.stringify(renderArg)).toContain('Not found'); + expect(output.id).toBe('rec-xyz-789'); }); - it('exits with code 1 on error', async () => { - mockDeleteRecommendation.mockRejectedValue(new Error('fail')); - + it('throws when engine.archive fails', async () => { + mockArchive.mockResolvedValue({ success: false, error: new Error('Not found') }); await expect( program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }) - ).rejects.toThrow('process.exit'); - - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it('calls requireProject', async () => { - mockDeleteRecommendation.mockResolvedValue(recommendationResult); - - await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); - - expect(mockRequireProject).toHaveBeenCalled(); + ).rejects.toThrow('Not found'); }); }); }); diff --git a/src/cli/commands/archive/command.tsx b/src/cli/commands/archive/command.tsx index 4cf042bee..d5aab6c03 100644 --- a/src/cli/commands/archive/command.tsx +++ b/src/cli/commands/archive/command.tsx @@ -1,61 +1,31 @@ -import { deleteBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; -import { deleteRecommendation } from '../../aws/agentcore-recommendation'; -import { COMMAND_DESCRIPTIONS } from '../../constants'; -import { getErrorMessage } from '../../errors'; -import { deleteLocalBatchEvalRun, deleteLocalRecommendationRun } from '../../operations/archive/archive-storage'; +import { ConfigIO } from '../../../lib'; +import { createJobEngine } from '../../operations/jobs'; +import type { JobType } from '../../operations/jobs'; +import { runCliCommand } from '../../telemetry/cli-command-run'; +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; -import { getRegion } from '../shared/region-utils'; import type { Command } from '@commander-js/extra-typings'; -import { Text, render } from 'ink'; -import React from 'react'; -async function executeArchive( +/** Archive a job: delete it from the service and remove the local .cli record via the engine. */ +function executeArchive( + jobType: JobType, cliOptions: { id: string; region?: string; json?: boolean }, - config: { - serviceDelete: (id: string, region: string) => Promise; - localDelete: (id: string) => boolean; - getId: (result: T) => string; - successMessage: string; - } -): Promise { + label: string +): Promise { requireProject(); - try { - const region = await getRegion(cliOptions.region); - const result = await config.serviceDelete(cliOptions.id, region); - - let localCliHistoryDeleted = false; - let localDeleteWarning: string | undefined; - try { - localCliHistoryDeleted = config.localDelete(cliOptions.id); - } catch (err) { - localDeleteWarning = getErrorMessage(err); - } - - if (cliOptions.json) { - console.log( - JSON.stringify({ - success: true, - ...result, - localCliHistoryDeleted, - ...(localDeleteWarning && { localDeleteWarning }), - }) - ); - } else { - console.log(`\n${config.successMessage}`); - console.log(`ID: ${config.getId(result)}`); - console.log(`Status: ${result.status}`); - if (localCliHistoryDeleted) console.log(`Local history cleared.`); - if (localDeleteWarning) console.log(`Warning: could not clear local history: ${localDeleteWarning}`); - console.log(''); + return runCliCommand('archive.job', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + const result = await engine.archive(jobType, cliOptions.id); + if (!result.success) { + throw result.error; } - } catch (error) { if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + console.log(JSON.stringify({ success: true, id: cliOptions.id })); } else { - render(Error: {getErrorMessage(error)}); + console.log(`\n✓ ${label} ${cliOptions.id} archived.\n`); } - process.exit(1); - } + return { job_type: jobType }; + }); } export const registerArchive = (program: Command) => { @@ -63,31 +33,41 @@ export const registerArchive = (program: Command) => { archiveCmd .command('batch-evaluation') - .description('[preview] Archive (delete) a batch evaluation on the service and clear local history') + .description('Archive a batch evaluation job record on the service and clear local history') .requiredOption('-i, --id ', 'Batch evaluation ID to archive') .option('--region ', 'AWS region (auto-detected if omitted)') .option('--json', 'Output as JSON') .action((cliOptions: { id: string; region?: string; json?: boolean }) => - executeArchive(cliOptions, { - serviceDelete: (id, region) => deleteBatchEvaluation({ region, batchEvaluationId: id }), - localDelete: deleteLocalBatchEvalRun, - getId: result => result.batchEvaluationId, - successMessage: 'Batch evaluation archived successfully', - }) + executeArchive('batch-evaluation', cliOptions, 'Batch evaluation') ); archiveCmd .command('recommendation') - .description('[preview] Archive (delete) a recommendation on the service and clear local history') + .description('Archive a recommendation job record on the service and clear local history') .requiredOption('-i, --id ', 'Recommendation ID to archive') .option('--region ', 'AWS region (auto-detected if omitted)') .option('--json', 'Output as JSON') .action((cliOptions: { id: string; region?: string; json?: boolean }) => - executeArchive(cliOptions, { - serviceDelete: (id, region) => deleteRecommendation({ region, recommendationId: id }), - localDelete: deleteLocalRecommendationRun, - getId: result => result.recommendationId, - successMessage: 'Recommendation archived successfully', - }) + executeArchive('recommendation', cliOptions, 'Recommendation') + ); + + archiveCmd + .command('ab-test') + .description('Archive (delete) an A/B test on the service and clear local history') + .requiredOption('-i, --id ', 'A/B test ID to archive') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--json', 'Output as JSON') + .action((cliOptions: { id: string; region?: string; json?: boolean }) => + executeArchive('ab-test', cliOptions, 'A/B test') + ); + + archiveCmd + .command('insights') + .description('[preview] Archive an insights job record on the service and clear local history') + .requiredOption('-i, --id ', 'Insights job ID to archive') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--json', 'Output as JSON') + .action((cliOptions: { id: string; region?: string; json?: boolean }) => + executeArchive('insights', cliOptions, 'Insights job') ); }; diff --git a/src/cli/commands/batch-evaluations/command.tsx b/src/cli/commands/batch-evaluations/command.tsx new file mode 100644 index 000000000..b4b4562ae --- /dev/null +++ b/src/cli/commands/batch-evaluations/command.tsx @@ -0,0 +1,57 @@ +import { ConfigIO, JobNotFoundError, serializeResult } from '../../../lib'; +import { createJobEngine } from '../../operations/jobs'; +import { printBatchEvaluationDetail, printBatchEvaluationHistory } from '../../operations/jobs/batch-evaluation/format'; +import { runCliCommand } from '../../telemetry/cli-command-run'; +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; +import { requireProject } from '../../tui/guards'; +import type { Command } from '@commander-js/extra-typings'; + +export const registerBatchEvaluations = (program: Command) => { + const cmd = program.command('batch-evaluations').description(COMMAND_DESCRIPTIONS.batchEvaluations); + + cmd + .command('history') + .description('List batch evaluation jobs (running jobs are refreshed from the service)') + .option('--json', 'Output as JSON') + .action((cliOptions: { json?: boolean }) => { + requireProject(); + return runCliCommand('job.history', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + const records = await engine.list({ type: 'batch-evaluation' }); + if (cliOptions.json) { + console.log( + JSON.stringify({ + success: true, + batchEvaluations: records, + }) + ); + } else { + printBatchEvaluationHistory(records); + } + return { job_type: 'batch-evaluation' }; + }); + }); + + // Bare positional on the group: `agentcore batch-evaluations ` shows one job. + // (No .description() here — that would override the group description shown in the command list.) + cmd + .argument('', 'Batch evaluation job ID to view') + .option('--json', 'Output as JSON') + .action((id: string, cliOptions: { json?: boolean }) => { + requireProject(); + return runCliCommand('job.get', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + const record = await engine.get('batch-evaluation', id); + if (!record) { + // Throw only — runCliCommand owns error output (single JSON line in --json, stderr otherwise). + throw new JobNotFoundError(`Batch evaluation "${id}" not found.`); + } + if (cliOptions.json) { + console.log(JSON.stringify(serializeResult({ success: true, ...record }))); + } else { + printBatchEvaluationDetail(record); + } + return { job_type: 'batch-evaluation' }; + }); + }); +}; diff --git a/src/cli/commands/batch-evaluations/index.ts b/src/cli/commands/batch-evaluations/index.ts new file mode 100644 index 000000000..1f11554d6 --- /dev/null +++ b/src/cli/commands/batch-evaluations/index.ts @@ -0,0 +1 @@ +export { registerBatchEvaluations } from './command'; diff --git a/src/cli/commands/config-bundle/command.tsx b/src/cli/commands/config-bundle/command.tsx index ce72f2a4b..1fd118985 100644 --- a/src/cli/commands/config-bundle/command.tsx +++ b/src/cli/commands/config-bundle/command.tsx @@ -8,6 +8,7 @@ import type { ListConfigurationBundleVersionsFilter, } from '../../aws/agentcore-config-bundles'; import { getErrorMessage } from '../../errors'; +import { isGatedFeaturesEnabled } from '../../feature-flags'; import { deepDiff } from '../../operations/config-bundle/diff-versions'; import { resolveBundleByName } from '../../operations/config-bundle/resolve-bundle'; import { requireProject } from '../../tui/guards'; @@ -44,7 +45,7 @@ async function resolveRegion(): Promise { // ============================================================================ async function handleVersions(options: { - bundle: string; + name: string; branch?: string; latestPerBranch?: boolean; createdBy?: string; @@ -52,7 +53,7 @@ async function handleVersions(options: { json?: boolean; }) { const region = options.region ?? (await resolveRegion()); - const resolved = await resolveBundleByName(options.bundle, region); + const resolved = await resolveBundleByName(options.name, region); const filter: ListConfigurationBundleVersionsFilter = {}; if (options.branch) filter.branchName = options.branch; @@ -78,16 +79,16 @@ async function handleVersions(options: { // Sort by creation time, newest first allVersions.sort((a, b) => Number(b.versionCreatedAt) - Number(a.versionCreatedAt)); - return { versions: allVersions, bundleName: options.bundle, bundleId: resolved.bundleId }; + return { versions: allVersions, bundleName: options.name, bundleId: resolved.bundleId }; } // ============================================================================ // Diff // ============================================================================ -async function handleDiff(options: { bundle: string; from: string; to: string; region?: string }) { +async function handleDiff(options: { name: string; from: string; to: string; region?: string }) { const region = options.region ?? (await resolveRegion()); - const resolved = await resolveBundleByName(options.bundle, region); + const resolved = await resolveBundleByName(options.name, region); const [fromVersion, toVersion] = await Promise.all([ getConfigurationBundleVersion({ region, bundleId: resolved.bundleId, versionId: options.from }), @@ -107,13 +108,13 @@ export const registerConfigBundle = (program: Command) => { const cmd = program .command('config-bundle') .alias('cb') - .description('[preview] Manage configuration bundles (use bundle name from agentcore.json, not the ID)'); + .description('Manage configuration bundles (use bundle name from agentcore.json, not the ID)'); // --- versions --- cmd .command('versions') .description('List version history for a configuration bundle') - .requiredOption('--bundle ', 'Bundle name as defined in agentcore.json (e.g. "MyBundle")') + .requiredOption('--name ', 'Bundle name as defined in agentcore.json (e.g. "MyBundle")') .option('--branch ', 'Filter by branch name') .option('--latest-per-branch', 'Show only the latest version per branch') .option('--created-by ', 'Filter by creator name (e.g. "user", "recommendation")') @@ -121,7 +122,7 @@ export const registerConfigBundle = (program: Command) => { .option('--json', 'Output as JSON') .action( async (cliOptions: { - bundle: string; + name: string; branch?: string; latestPerBranch?: boolean; createdBy?: string; @@ -138,7 +139,7 @@ export const registerConfigBundle = (program: Command) => { } if (result.versions.length === 0) { - render(No versions found for bundle "{cliOptions.bundle}".); + render(No versions found for bundle "{cliOptions.name}".); return; } @@ -199,12 +200,12 @@ export const registerConfigBundle = (program: Command) => { cmd .command('diff') .description('Diff two versions of a configuration bundle (get version IDs from `cb versions`)') - .requiredOption('--bundle ', 'Bundle name as defined in agentcore.json (e.g. "MyBundle")') + .requiredOption('--name ', 'Bundle name as defined in agentcore.json (e.g. "MyBundle")') .requiredOption('--from ', 'Source version ID (from `config-bundle versions --json`)') .requiredOption('--to ', 'Target version ID (from `config-bundle versions --json`)') .option('--region ', 'AWS region override') .option('--json', 'Output as JSON') - .action(async (cliOptions: { bundle: string; from: string; to: string; region?: string; json?: boolean }) => { + .action(async (cliOptions: { name: string; from: string; to: string; region?: string; json?: boolean }) => { requireProject(); try { const result = await handleDiff(cliOptions); @@ -258,11 +259,12 @@ export const registerConfigBundle = (program: Command) => { } }); - // --- create-branch --- + // --- create-branch: gated until upstream CFN read-back bug is fixed for non-default branches --- + if (!isGatedFeaturesEnabled()) return cmd; cmd .command('create-branch') .description('Create a new branch on an existing configuration bundle') - .requiredOption('--bundle ', 'Bundle name as defined in agentcore.json (e.g. "MyBundle")') + .requiredOption('--name ', 'Bundle name as defined in agentcore.json (e.g. "MyBundle")') .requiredOption('--branch ', 'Name for the new branch') .option('--from ', 'Parent version ID to branch from (defaults to latest version)') .option('--commit-message ', 'Commit message for the branch point') @@ -270,7 +272,7 @@ export const registerConfigBundle = (program: Command) => { .option('--json', 'Output as JSON') .action( async (cliOptions: { - bundle: string; + name: string; branch: string; from?: string; commitMessage?: string; @@ -280,7 +282,7 @@ export const registerConfigBundle = (program: Command) => { requireProject(); try { const region = cliOptions.region ?? (await resolveRegion()); - const resolved = await resolveBundleByName(cliOptions.bundle, region); + const resolved = await resolveBundleByName(cliOptions.name, region); // Determine parent version let parentVersionId = cliOptions.from; @@ -291,7 +293,7 @@ export const registerConfigBundle = (program: Command) => { maxResults: 50, }); if (versions.versions.length === 0) { - throw new Error(`No versions found for bundle "${cliOptions.bundle}".`); + throw new Error(`No versions found for bundle "${cliOptions.name}".`); } // Sort descending by creation time to get the latest version const sorted = [...versions.versions].sort( @@ -314,6 +316,7 @@ export const registerConfigBundle = (program: Command) => { parentVersionIds: [parentVersionId], branchName: cliOptions.branch, commitMessage: cliOptions.commitMessage ?? `Create branch ${cliOptions.branch}`, + createdBy: { name: 'user' }, }); if (cliOptions.json) { @@ -324,7 +327,7 @@ export const registerConfigBundle = (program: Command) => { render( - Branch "{cliOptions.branch}" created on bundle "{cliOptions.bundle}" + Branch "{cliOptions.branch}" created on bundle "{cliOptions.name}" Version: {result.versionId} diff --git a/src/cli/commands/create/__tests__/harness-validate.test.ts b/src/cli/commands/create/__tests__/harness-validate.test.ts index 61ab2d87c..a7f82c608 100644 --- a/src/cli/commands/create/__tests__/harness-validate.test.ts +++ b/src/cli/commands/create/__tests__/harness-validate.test.ts @@ -112,6 +112,30 @@ describe('validateCreateHarnessOptions - apiFormat', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// lite_llm provider +// ───────────────────────────────────────────────────────────────────────────── + +describe('validateCreateHarnessOptions - lite_llm', () => { + it('accepts lite_llm without an API key ARN (key is optional)', () => { + const result = validateCreateHarnessOptions({ ...baseOptions, modelProvider: 'lite_llm' }, makeCwd()); + expect(result.valid).toBe(true); + }); + + it('normalizes the litellm alias to lite_llm', () => { + const options = { ...baseOptions, modelProvider: 'litellm' }; + const result = validateCreateHarnessOptions(options, makeCwd()); + expect(result.valid).toBe(true); + expect(options.modelProvider).toBe('lite_llm'); + }); + + it('still requires an API key ARN for open_ai', () => { + const result = validateCreateHarnessOptions({ ...baseOptions, modelProvider: 'open_ai' }, makeCwd()); + expect(result.valid).toBe(false); + expect(result.error).toContain('--api-key-arn is required'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // EFS access point validation // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/cli/commands/create/__tests__/validate.test.ts b/src/cli/commands/create/__tests__/validate.test.ts index 29c6caf76..e54687b59 100644 --- a/src/cli/commands/create/__tests__/validate.test.ts +++ b/src/cli/commands/create/__tests__/validate.test.ts @@ -148,6 +148,32 @@ describe('validateCreateOptions', () => { expect(result.error).toContain('is not yet available for TypeScript'); }); + it('rejects Python with the Vercel AI framework (TypeScript-only)', () => { + const result = validateCreateOptions( + { name: 'TestProjVercel', language: 'Python', framework: 'VercelAI', modelProvider: 'Bedrock', memory: 'none' }, + testDir + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('is not yet available for Python'); + // Message lists the language's supported frameworks (derived from the matrix), not a hardcoded one + expect(result.error).toContain('Strands'); + expect(result.error).toContain('OpenAIAgents'); + }); + + it('accepts TypeScript with the Vercel AI framework', () => { + const result = validateCreateOptions( + { + name: 'TestProjVercelTs', + language: 'TypeScript', + framework: 'VercelAI', + modelProvider: 'Bedrock', + memory: 'none', + }, + testDir + ); + expect(result.valid).toBe(true); + }); + it('returns invalid for invalid framework', () => { const result = validateCreateOptions( { name: 'TestProj5', language: 'Python', framework: 'InvalidFW', modelProvider: 'Bedrock', memory: 'none' }, diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index b5a934b50..a147b4776 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -12,6 +12,7 @@ import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema'; import { ANSI, COMMAND_DESCRIPTIONS } from '../../constants'; import { getErrorMessage } from '../../errors'; import { isPreviewEnabled } from '../../feature-flags'; +import { ADDITIONAL_PARAMS_JSON_ERROR } from '../../primitives/constants'; import { harnessPrimitive } from '../../primitives/registry'; import { runCliCommand, withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { @@ -93,6 +94,8 @@ const AGENT_PATH_FLAGS = ['framework', 'language', 'build', 'protocol', 'type', const HARNESS_ONLY_FLAGS = [ 'modelId', 'apiKeyArn', + 'apiBase', + 'additionalParams', 'maxIterations', 'maxTokens', 'timeout', @@ -184,11 +187,21 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise { bedrock: 'global.anthropic.claude-sonnet-4-6', open_ai: 'gpt-5', gemini: 'gemini-2.5-flash', + lite_llm: 'anthropic/claude-sonnet-4-5', }; const modelId = options.modelId ?? defaultModelIds[provider] ?? 'global.anthropic.claude-sonnet-4-6'; const containerOption = harnessPrimitive!.parseContainerFlag(options.container); + let additionalParams: Record | undefined; + if (options.additionalParams) { + try { + additionalParams = JSON.parse(options.additionalParams) as Record; + } catch { + throw new Error(ADDITIONAL_PARAMS_JSON_ERROR); + } + } + const { efsMounts: harnessEfsMounts, s3Mounts: harnessS3Mounts } = await resolveAndValidateFilesystemMounts( options, parseCommaSeparatedList @@ -201,6 +214,8 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise { modelProvider: provider, modelId, apiKeyArn: options.apiKeyArn, + apiBase: options.apiBase, + additionalParams, containerUri: containerOption.containerUri, dockerfilePath: containerOption.dockerfilePath, skipMemory: options.harnessMemory === false, @@ -430,7 +445,7 @@ export const registerCreate = (program: Command) => { (val: string, prev: string[]) => [...prev, val], [] as string[] ) - .option('--with-config-bundle', 'Create a config bundle wired into the agent template [preview] [non-interactive]') + .option('--with-config-bundle', 'Create a config bundle wired into the agent template [non-interactive]') .option('--output-dir

', 'Output directory (default: current directory) [non-interactive]') .option('--skip-git', 'Skip git repository initialization [non-interactive]') .option('--skip-python-setup', 'Skip Python virtual environment setup [non-interactive]') @@ -440,20 +455,22 @@ export const registerCreate = (program: Command) => { if (isPreviewEnabled()) { createCmd - .option('--model-id ', 'Model ID for harness [non-interactive] [preview]') - .option('--api-key-arn ', 'API key ARN for non-Bedrock harness providers [non-interactive] [preview]') - .option('--no-harness-memory', 'Skip auto-creating memory for harness [non-interactive] [preview]') - .option('--max-iterations ', 'Max agent loop iterations (harness) [non-interactive] [preview]') - .option('--max-tokens ', 'Max tokens per iteration (harness) [non-interactive] [preview]') - .option('--timeout ', 'Max execution duration in seconds (harness) [non-interactive] [preview]') + .option('--model-id ', 'Model ID for harness [non-interactive]') + .option('--api-key-arn ', 'API key ARN for non-Bedrock harness providers [non-interactive]') + .option('--api-base ', 'Base URL for the harness model provider API endpoint (lite_llm) [non-interactive]') .option( - '--truncation-strategy ', - 'Truncation strategy: sliding_window or summarization (harness) [non-interactive] [preview]' + '--additional-params ', + 'Provider-specific harness params as a JSON object (lite_llm) [non-interactive]' ) + .option('--no-harness-memory', 'Skip auto-creating memory for harness [non-interactive]') + .option('--max-iterations ', 'Max agent loop iterations (harness) [non-interactive]') + .option('--max-tokens ', 'Max tokens per iteration (harness) [non-interactive]') + .option('--timeout ', 'Max execution duration in seconds (harness) [non-interactive]') .option( - '--container ', - 'Container image URI or Dockerfile path (harness) [non-interactive] [preview]' - ); + '--truncation-strategy ', + 'Truncation strategy: sliding_window or summarization (harness) [non-interactive]' + ) + .option('--container ', 'Container image URI or Dockerfile path (harness) [non-interactive]'); } createCmd.action(async (rawOptions: Record) => { diff --git a/src/cli/commands/create/harness-action.ts b/src/cli/commands/create/harness-action.ts index 3354ae641..68cbfff6e 100644 --- a/src/cli/commands/create/harness-action.ts +++ b/src/cli/commands/create/harness-action.ts @@ -14,6 +14,8 @@ export interface CreateHarnessProjectOptions { modelId: string; apiFormat?: HarnessApiFormat; apiKeyArn?: string; + apiBase?: string; + additionalParams?: Record; skipMemory?: boolean; containerUri?: string; dockerfilePath?: string; @@ -62,6 +64,8 @@ export async function createProjectWithHarness(options: CreateHarnessProjectOpti modelId: options.modelId, apiFormat: options.apiFormat, apiKeyArn: options.apiKeyArn, + apiBase: options.apiBase, + additionalParams: options.additionalParams, containerUri: options.containerUri, dockerfilePath: options.dockerfilePath, skipMemory: options.skipMemory, diff --git a/src/cli/commands/create/harness-validate.ts b/src/cli/commands/create/harness-validate.ts index feedf0784..926cda032 100644 --- a/src/cli/commands/create/harness-validate.ts +++ b/src/cli/commands/create/harness-validate.ts @@ -1,5 +1,10 @@ -import { MAX_EFS_MOUNTS, MAX_S3_MOUNTS, validateApiFormat } from '../../../schema'; -import { HarnessNameSchema, ProjectNameSchema } from '../../../schema'; +import { + HarnessNameSchema, + MAX_EFS_MOUNTS, + MAX_S3_MOUNTS, + ProjectNameSchema, + validateApiFormat, +} from '../../../schema'; import { validateAccessPointMounts, validateEfsAccessPointArn, @@ -53,6 +58,9 @@ const MODEL_PROVIDER_MAPPING: Record = { Anthropic: 'bedrock', gemini: 'gemini', Gemini: 'gemini', + lite_llm: 'lite_llm', + litellm: 'lite_llm', + LiteLLM: 'lite_llm', }; export function normalizeHarnessModelProvider(raw: string): string | undefined { @@ -85,7 +93,7 @@ export function validateCreateHarnessOptions(options: CreateHarnessCliOptions, c if (!normalized) { return { valid: false, - error: `Invalid model provider: ${options.modelProvider}. Use bedrock, open_ai, or gemini`, + error: `Invalid model provider: ${options.modelProvider}. Use bedrock, open_ai, gemini, or lite_llm`, }; } options.modelProvider = normalized; @@ -96,10 +104,12 @@ export function validateCreateHarnessOptions(options: CreateHarnessCliOptions, c bedrock: 'global.anthropic.claude-sonnet-4-6', open_ai: 'gpt-5', gemini: 'gemini-2.5-flash', + lite_llm: 'anthropic/claude-sonnet-4-5', }; options.modelId ??= defaultModelIds[options.modelProvider] ?? 'global.anthropic.claude-sonnet-4-6'; - if (options.modelProvider !== 'bedrock' && !options.apiKeyArn) { + // open_ai and gemini require an API key; bedrock uses AWS credentials and lite_llm's key is optional. + if (options.modelProvider !== 'bedrock' && options.modelProvider !== 'lite_llm' && !options.apiKeyArn) { return { valid: false, error: `--api-key-arn is required for ${options.modelProvider} provider` }; } diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index 947160e2d..67e40b20f 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -34,6 +34,8 @@ export interface CreateOptions extends VpcOptions { // Harness-specific (preview only) modelId?: string; apiKeyArn?: string; + apiBase?: string; + additionalParams?: string; container?: string; harnessMemory?: boolean; maxIterations?: string; diff --git a/src/cli/commands/create/validate.ts b/src/cli/commands/create/validate.ts index 2a4030c20..ad6dac618 100644 --- a/src/cli/commands/create/validate.ts +++ b/src/cli/commands/create/validate.ts @@ -9,8 +9,10 @@ import { SDKFrameworkSchema, SessionStorageSchema, TargetLanguageSchema, + getFrameworksForLanguage, getSupportedFrameworksForProtocol, getSupportedModelProviders, + isFrameworkSupportedForLanguage, matchEnumValue, } from '../../../schema'; import type { ProtocolMode } from '../../../schema'; @@ -200,11 +202,16 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val return { valid: false, error: `Invalid model provider: ${options.modelProvider}` }; } - // TypeScript supports Strands and Vercel AI only - if (options.language === 'TypeScript' && fwResult.data !== 'Strands' && fwResult.data !== 'VercelAI') { + // Framework must ship a template for the chosen language (e.g. Vercel AI is + // TypeScript-only, the other open-source frameworks are Python-only). + if ( + (langResult.data === 'Python' || langResult.data === 'TypeScript') && + !isFrameworkSupportedForLanguage(langResult.data, fwResult.data) + ) { + const supported = getFrameworksForLanguage(langResult.data).join(', '); return { valid: false, - error: `Framework ${options.framework} is not yet available for TypeScript. Only Strands and Vercel AI SDK are supported.`, + error: `Framework ${options.framework} is not yet available for ${langResult.data}. Supported: ${supported}.`, }; } diff --git a/src/cli/commands/deploy/__tests__/harness-version-drift.test.ts b/src/cli/commands/deploy/__tests__/harness-version-drift.test.ts new file mode 100644 index 000000000..1a9b45a97 --- /dev/null +++ b/src/cli/commands/deploy/__tests__/harness-version-drift.test.ts @@ -0,0 +1,35 @@ +import { computeHarnessVersionDrift } from '../actions.js'; +import { describe, expect, it } from 'vitest'; + +describe('computeHarnessVersionDrift', () => { + it('reports a first deploy (no prior version) as deployed at the new version', () => { + const notes = computeHarnessVersionDrift(undefined, { h1: { harnessVersion: 1 } }); + expect(notes).toEqual([{ name: 'h1', from: undefined, to: 1 }]); + }); + + it('reports a version bump as from → to', () => { + const notes = computeHarnessVersionDrift({ h1: { harnessVersion: 2 } }, { h1: { harnessVersion: 3 } }); + expect(notes).toEqual([{ name: 'h1', from: 2, to: 3 }]); + }); + + it('emits no note when the version is unchanged', () => { + const notes = computeHarnessVersionDrift({ h1: { harnessVersion: 3 } }, { h1: { harnessVersion: 3 } }); + expect(notes).toEqual([]); + }); + + it('skips harnesses that emit no version (legacy stack)', () => { + const notes = computeHarnessVersionDrift(undefined, { h1: {} }); + expect(notes).toEqual([]); + }); + + it('handles multiple harnesses independently', () => { + const notes = computeHarnessVersionDrift( + { a: { harnessVersion: 1 }, b: { harnessVersion: 5 } }, + { a: { harnessVersion: 2 }, b: { harnessVersion: 5 }, c: { harnessVersion: 1 } } + ); + expect(notes).toEqual([ + { name: 'a', from: 1, to: 2 }, + { name: 'c', from: undefined, to: 1 }, + ]); + }); +}); diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 404291381..6b2a5b30b 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,5 +1,5 @@ import { ConfigIO, ResourceNotFoundError, SecureCredentials, ValidationError, toError } from '../../../lib'; -import type { AgentCoreMcpSpec, DeployedState, HarnessDeployedState } from '../../../schema'; +import type { AgentCoreMcpSpec, DeployedState } from '../../../schema'; import { applyTargetRegionToEnv } from '../../aws'; import { validateAwsCredentials } from '../../aws/account'; import { CdkToolkitWrapper, createSwitchableIoHost } from '../../cdk/toolkit-lib'; @@ -8,9 +8,12 @@ import { buildDeployedState, getStackOutputs, parseAgentOutputs, + parseConfigBundleOutputs, parseDatasetOutputs, parseEvaluatorOutputs, parseGatewayOutputs, + parseHarnessOutputs, + parseKnowledgeBaseOutputs, parseMemoryOutputs, parseOnlineEvalOutputs, parsePaymentOutputs, @@ -19,9 +22,10 @@ import { parseRuntimeEndpointOutputs, } from '../../cloudformation'; import { getErrorMessage } from '../../errors'; -import { isPreviewEnabled } from '../../feature-flags'; +import { isGatedFeaturesEnabled, isPreviewEnabled } from '../../feature-flags'; import { ExecLogger } from '../../logging'; import { + MANAGED_MEMORY_DEPLOY_NOTICE, assertEnvFileExists, bootstrapEnvironment, buildCdkProject, @@ -31,6 +35,7 @@ import { getAllCredentials, hasIdentityApiProviders, hasIdentityOAuthProviders, + hasManagedMemoryHarness, performStackTeardown, setupApiKeyProviders, setupOAuth2Providers, @@ -40,20 +45,16 @@ import { } from '../../operations/deploy'; import { computeProjectDeployHash } from '../../operations/deploy/change-detection'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; -import { type ImperativeDeployContext, createDeploymentManager } from '../../operations/deploy/imperative'; -import { deleteOrphanedABTests, setupABTests } from '../../operations/deploy/post-deploy-ab-tests'; -import { - resolveConfigBundleComponentKeys, - setupConfigBundles, -} from '../../operations/deploy/post-deploy-config-bundles'; import { syncDatasets } from '../../operations/deploy/post-deploy-datasets'; -import { setupHttpGateways } from '../../operations/deploy/post-deploy-http-gateways'; +import { autoIngestKnowledgeBases } from '../../operations/deploy/post-deploy-knowledge-bases'; import { enableOnlineEvalConfigs } from '../../operations/deploy/post-deploy-online-evals'; import { cleanupPaymentCredentialProviders, hasPaymentCredentialProviders, setupPaymentCredentialProviders, } from '../../operations/deploy/pre-deploy-identity'; +import { findOrphanHarnesses } from '../../operations/harness/orphan'; +import { hydrateKnowledgeBaseDataSources } from '../../operations/knowledge-base/hydrate-data-sources'; import { toStackName } from '../import/import-utils'; import type { DeployResult } from './types'; import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; @@ -67,11 +68,32 @@ export interface ValidatedDeployOptions { onProgress?: (step: string, status: 'start' | 'success' | 'error') => void; onResourceEvent?: (message: string) => void; onDeployMessage?: (message: DeployMessage) => void; + /** Emit a one-shot, user-facing notice to the terminal mid-deploy (e.g. the managed-memory heads-up + * shown before the slow CFN apply). Always surfaced, independent of `verbose`. */ + onNotice?: (message: string) => void; } const AGENT_NEXT_STEPS = ['agentcore invoke', 'agentcore status']; const MEMORY_ONLY_NEXT_STEPS = ['agentcore add agent', 'agentcore status']; +/** + * Compute per-harness config-version drift between the previous deployed-state and the freshly-parsed + * harness outputs. Only harnesses that emit a version are considered; a changed (or first-seen) version + * yields a note. Pure + exported for unit testing. + */ +export function computeHarnessVersionDrift( + prev: Record | undefined, + next: Record +): { name: string; from?: number; to: number }[] { + const notes: { name: string; from?: number; to: number }[] = []; + for (const [name, rec] of Object.entries(next)) { + if (rec.harnessVersion === undefined) continue; + const from = prev?.[name]?.harnessVersion; + if (from !== rec.harnessVersion) notes.push({ name, from, to: rec.harnessVersion }); + } + return notes; +} + export async function runDiff( toolkitWrapper: CdkToolkitWrapper, stackName: string, @@ -163,6 +185,27 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise undefined); + for (const orphan of findOrphanHarnesses(preDeployState)) { + const warning = + `Harness "${orphan.name}" was created by the preview build and is not managed by ` + + `CloudFormation. This deploy won't touch it, and it keeps incurring cost. To migrate it ` + + `to GA, run \`agentcore remove harness ${orphan.name} --keep\` (deletes the old resource ` + + `but keeps it in agentcore.json), then deploy again so it's recreated under CloudFormation. ` + + `Use --discard instead if you no longer want it.`; + logger.log(warning, 'warn'); + orphanWarnings.push(warning); + } + } + // Teardown confirmation: if this is a teardown deploy, require --yes if (context.isTeardownDeploy && !options.autoConfirm) { logger.finalize(false); @@ -413,6 +456,18 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0; const deployStepName = hasGateways ? 'Deploying gateways...' : 'Deploy to AWS'; @@ -438,38 +493,8 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise ({ targets: {} }) as DeployedState); - const teardownContext: ImperativeDeployContext = { - projectSpec: context.projectSpec, - target, - configIO, - deployedState: existingTeardownState, - onProgress: (step: string, status: 'start' | 'done' | 'error') => { - logger.log(`${step}: ${status}`); - }, - }; - - if (imperativeManager.hasDeployersForPhase('post-cdk', teardownContext)) { - startStep('Tear down imperative resources'); - const imperativeTeardown = await imperativeManager.teardownAll(teardownContext); - if (!imperativeTeardown.success) { - endStep('error', imperativeTeardown.error); - logger.finalize(false); - return { - success: false, - error: new Error(`Imperative teardown failed: ${imperativeTeardown.error}`), - logPath: logger.getRelativeLogPath(), - }; - } - endStep('success'); - } - } - - // Clean up imperative payment credential providers (CFN stack delete handles manager/connector/roles) + // Clean up imperative payment credential providers (CFN stack delete handles manager/connector/roles). + // Harnesses are part of the CloudFormation stack, so stack destroy handles them. const existingDeployedState = await configIO.readDeployedState().catch(() => undefined); const existingPayments = existingDeployedState?.targets?.[target.name]?.resources?.payments; if (existingPayments && Object.keys(existingPayments).length > 0) { @@ -560,20 +585,67 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { - acc[gateway.name] = gateway; - return acc; - }, - {} as Record - ) ?? {}; - const gateways = parseGatewayOutputs(outputs, gatewaySpecs); + const allGatewaySpecs = mcpSpec?.agentCoreGateways ?? []; + const gatewaySpecs = allGatewaySpecs.reduce( + (acc, gateway) => { + acc[gateway.name] = gateway; + return acc; + }, + {} as Record + ); + const allGateways = parseGatewayOutputs(outputs, gatewaySpecs); + + // Split into MCP and HTTP gateways based on protocolType + const httpGatewayNames = new Set(allGatewaySpecs.filter(g => g.protocolType === 'None').map(g => g.name)); + const gateways: Record = {}; + const httpGateways: Record< + string, + { gatewayId: string; gatewayArn: string; gatewayUrl?: string; targets?: Record } + > = {}; + for (const [name, state] of Object.entries(allGateways)) { + if (httpGatewayNames.has(name)) { + httpGateways[name] = state; + } else { + gateways[name] = state; + } + } // Parse dataset outputs const datasetNames = (context.projectSpec.datasets ?? []).map(d => d.name); const datasets = parseDatasetOutputs(outputs, datasetNames); + // Parse config bundle outputs + const configBundleNames = (context.projectSpec.configBundles ?? []).map(b => b.name); + const configBundles = parseConfigBundleOutputs(outputs, configBundleNames); + + // Parse knowledge base outputs (CFN emits id+arn; DSes hydrated next via SDK). + const knowledgeBaseSpecs = context.projectSpec.knowledgeBases ?? []; + const knowledgeBaseNames = knowledgeBaseSpecs.map(kb => kb.name); + const knowledgeBases = parseKnowledgeBaseOutputs(outputs, knowledgeBaseNames); + + if (knowledgeBaseNames.length > 0 && Object.keys(knowledgeBases).length !== knowledgeBaseNames.length) { + logger.log( + `Deployed-state missing outputs for ${ + knowledgeBaseNames.length - Object.keys(knowledgeBases).length + } knowledge base(s).`, + 'warn' + ); + } + + // Hydrate dataSources[] for each KB by listing DSes via bedrock-agent + // (the L3 doesn't emit per-DS CFN outputs). + if (Object.keys(knowledgeBases).length > 0) { + try { + await hydrateKnowledgeBaseDataSources({ + knowledgeBases, + knowledgeBaseSpecs, + region: target.region, + }); + } catch (err) { + logger.log(`Failed to hydrate knowledge base data sources: ${getErrorMessage(err)}`, 'warn'); + } + } + // Parse payment outputs from CFN stack const paymentSpecs = (context.projectSpec.payments ?? []).map(p => ({ name: p.name, @@ -589,49 +661,14 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? parsePaymentOutputs(outputs, paymentSpecs) : undefined; - endStep('success'); - - // Post-CDK: deploy imperative resources (harness) — preview mode only - let deployedHarnesses: Record | undefined; - if (isPreviewEnabled()) { - const imperativeManager = createDeploymentManager(); - const existingImperativeState: DeployedState = await configIO.readDeployedState().catch(() => ({ targets: {} })); - const imperativeContext = { - projectSpec: context.projectSpec, - target, - configIO, - deployedState: existingImperativeState, - cdkOutputs: outputs, - onProgress: (step: string, status: 'start' | 'done' | 'error') => { - logger.log(`${step}: ${status}`); - }, - }; + // Parse harness outputs (harnesses are now part of the CloudFormation stack). + // Preview-gated: when preview is off the vended app never synthesizes a harness + // (see bin/cdk.ts), so there are no outputs to parse — skip entirely to keep the + // gate complete and avoid warning on a harness that was intentionally not deployed. + const harnessNames = isPreviewEnabled() ? (context.projectSpec.harnesses ?? []).map(h => h.name) : []; + const deployedHarnesses = parseHarnessOutputs(outputs, harnessNames); - let harnessDeployError: string | undefined; - if (imperativeManager.hasDeployersForPhase('post-cdk', imperativeContext)) { - startStep('Deploy harnesses'); - const postCdkResult = await imperativeManager.runPhase('post-cdk', imperativeContext); - const harnessResult = postCdkResult.results.get('harness'); - if (harnessResult?.state) { - deployedHarnesses = harnessResult.state as Record; - } - if (!postCdkResult.success) { - endStep('error', postCdkResult.error); - harnessDeployError = postCdkResult.error; - } else { - endStep('success'); - } - } - - if (harnessDeployError) { - logger.finalize(false); - return { - success: false, - error: new Error(`Harness deployment failed: ${harnessDeployError}`), - logPath: logger.getRelativeLogPath(), - }; - } - } + endStep('success'); let deployHash: string | undefined; try { @@ -641,11 +678,14 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise undefined); + // Capture prior harness version records BEFORE the new state overwrites them, for the drift note. + const prevHarnessRecords = existingState?.targets?.[target.name]?.resources?.harnesses; let deployedState = buildDeployedState({ targetName: target.name, stackName, agents, gateways, + httpGateways: Object.keys(httpGateways).length > 0 ? httpGateways : undefined, existingState, identityKmsKeyArn, credentials: deployedCredentials, @@ -657,7 +697,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise t.name), }); if (deployHash) { @@ -687,6 +730,18 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { const gatewayUrls = Object.entries(gateways) @@ -766,137 +821,52 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { - const deleteResult = await deleteOrphanedABTests({ + // Post-deploy: auto-trigger ingestion for any KB whose data-source URIs + // changed since the last deploy (or has never been ingested before). + const knowledgeBaseSpecsForIngest = context.projectSpec.knowledgeBases ?? []; + if (knowledgeBaseSpecsForIngest.length > 0) { + startStep('Auto-ingest knowledge bases'); + const ingestResult = await autoIngestKnowledgeBases({ region: target.region, - projectSpec: context.projectSpec, - existingABTests: existingABTestsForCleanup, + knowledgeBases: knowledgeBaseSpecsForIngest, + deployedKnowledgeBases: deployedState.targets?.[target.name]?.resources?.knowledgeBases ?? {}, + previousKnowledgeBases: existingState?.targets?.[target.name]?.resources?.knowledgeBases, + targetName: target.name, + deployedState, + onProgress: msg => logger.log(msg), }); - if (deleteResult.hasErrors) { - const errors = deleteResult.results.filter(r => r.status === 'error'); - const errorMessages = errors.map(err => `"${err.testName}": ${err.error}`).join('; '); - logger.log(`AB test orphan cleanup warnings: ${errorMessages}`, 'warn'); - postDeployWarnings.push(...errors.map(err => `AB test "${err.testName}": ${err.error}`)); - } - - // Surface warnings (e.g., "AB test was stopped before deletion") - for (const r of deleteResult.results) { - if (r.warning) { - logger.log(r.warning, 'warn'); - postDeployWarnings.push(r.warning); - } - } - - // Update deployed state to remove deleted AB tests - if (deleteResult.results.some(r => r.status === 'deleted')) { - const updatedState = await configIO.readDeployedState().catch(() => deployedState); - const targetResources = updatedState.targets[target.name]?.resources; - if (targetResources?.abTests) { - for (const r of deleteResult.results) { - if (r.status === 'deleted') delete targetResources.abTests[r.testName]; + // Persist new sourcesHash values for KBs whose ingestion fired. + const targetResources = deployedState.targets[target.name]?.resources; + if (targetResources?.knowledgeBases) { + for (const r of ingestResult.results) { + if (r.status === 'started' && r.newSourcesHash) { + const record = targetResources.knowledgeBases[r.knowledgeBaseName]; + if (record) record.sourcesHash = r.newSourcesHash; } - await configIO.writeDeployedState(updatedState); - deployedState = updatedState; } + await configIO.writeDeployedState(deployedState); } - } - - // Post-deploy: Create/update HTTP gateways for AB tests (must run BEFORE config bundles - // because config bundle component keys may reference gateway ARNs) - const httpGatewaySpecs = context.projectSpec.httpGateways ?? []; - const existingHttpGateways = deployedState.targets?.[target.name]?.resources?.httpGateways; - if (httpGatewaySpecs.length > 0 || Object.keys(existingHttpGateways ?? {}).length > 0) { - const deployedResources = deployedState.targets?.[target.name]?.resources; - const httpGatewayResult = await setupHttpGateways({ - region: target.region, - projectName: context.projectSpec.name, - projectSpec: context.projectSpec, - existingHttpGateways, - deployedResources, - }); - - // Always merge HTTP gateway state (even if empty, to clear deleted gateways) - const updatedState = await configIO.readDeployedState().catch(() => deployedState); - const targetResources = updatedState.targets[target.name]?.resources; - if (targetResources) { - targetResources.httpGateways = httpGatewayResult.httpGateways; - await configIO.writeDeployedState(updatedState); - deployedState = updatedState; - } - - if (httpGatewayResult.hasErrors) { - const errors = httpGatewayResult.results.filter(r => r.status === 'error'); - const errorMessages = errors.map(err => `"${err.gatewayName}": ${err.error}`).join('; '); - logger.log(`HTTP gateway setup warnings: ${errorMessages}`, 'warn'); - postDeployWarnings.push(...errors.map(err => `HTTP gateway "${err.gatewayName}": ${err.error}`)); - } - } - // Post-deploy: Create/update configuration bundles - const configBundleSpecs = context.projectSpec.configBundles ?? []; - if (configBundleSpecs.length > 0) { - // Resolve component key placeholders (e.g., {{gateway:name}} → real ARN) - const resolvedProjectSpec = resolveConfigBundleComponentKeys(context.projectSpec, deployedState, target.name); - - const existingConfigBundles = deployedState.targets?.[target.name]?.resources?.configBundles; - const configBundleResult = await setupConfigBundles({ - region: target.region, - projectSpec: resolvedProjectSpec, - existingBundles: existingConfigBundles, - }); - - // Merge config bundle state into deployed state - if (Object.keys(configBundleResult.configBundles).length > 0) { - const updatedState = await configIO.readDeployedState().catch(() => deployedState); - const targetResources = updatedState.targets[target.name]?.resources; - if (targetResources) { - targetResources.configBundles = configBundleResult.configBundles; - await configIO.writeDeployedState(updatedState); - deployedState = updatedState; + // Log per-KB result so the user sees what happened. + for (const r of ingestResult.results) { + if (r.status === 'started') { + logger.log( + `Knowledge base "${r.knowledgeBaseName}": ingestion started for ${r.startedJobCount} data source(s)` + ); + } else if (r.status === 'skipped') { + logger.log(`Knowledge base "${r.knowledgeBaseName}": skipped (${r.reason})`); + } else { + logger.log(`Knowledge base "${r.knowledgeBaseName}": ${r.error}`, 'warn'); + postDeployWarnings.push(`Knowledge base "${r.knowledgeBaseName}": ${r.error}`); } } - - if (configBundleResult.hasErrors) { - const errors = configBundleResult.results.filter(r => r.status === 'error'); - const errorMessages = errors.map(err => `"${err.bundleName}": ${err.error}`).join('; '); - logger.log(`Config bundle setup warnings: ${errorMessages}`, 'warn'); - postDeployWarnings.push(...errors.map(err => `Config bundle "${err.bundleName}": ${err.error}`)); - } + endStep(ingestResult.hasErrors ? 'error' : 'success'); } - // Post-deploy: Create/update AB tests - const abTestSpecs = context.projectSpec.abTests ?? []; - if (abTestSpecs.length > 0) { - const existingABTests = deployedState.targets?.[target.name]?.resources?.abTests; - const deployedResources = deployedState.targets?.[target.name]?.resources; - const abTestResult = await setupABTests({ - region: target.region, - projectSpec: context.projectSpec, - existingABTests, - deployedResources, - }); - - // Merge AB test state into deployed state - if (Object.keys(abTestResult.abTests).length > 0) { - const updatedState = await configIO.readDeployedState().catch(() => deployedState); - const targetResources = updatedState.targets[target.name]?.resources; - if (targetResources) { - targetResources.abTests = abTestResult.abTests; - await configIO.writeDeployedState(updatedState); - } - } - - if (abTestResult.hasErrors) { - const errors = abTestResult.results.filter(r => r.status === 'error'); - const errorMessages = errors.map(err => `"${err.testName}": ${err.error}`).join('; '); - logger.log(`AB test setup warnings: ${errorMessages}`, 'warn'); - postDeployWarnings.push(...errors.map(err => `AB test "${err.testName}": ${err.error}`)); - } - } + // Config bundles are now managed via CloudFormation; their state is parsed + // from stack outputs above (no post-deploy API step). AB tests are managed + // as fire-and-forget jobs (agentcore run ab-test), not via the deploy path. // Post-deploy: Enable CloudWatch Transaction Search (non-blocking, silent) const hasHarnesses = isPreviewEnabled() && (context.projectSpec.harnesses ?? []).length > 0; @@ -927,6 +897,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? postDeployWarnings : undefined, + postDeployWarnings: allWarnings.length > 0 ? allWarnings : undefined, }; } catch (err: unknown) { logger.log(getErrorMessage(err), 'error'); @@ -948,13 +922,3 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { } : undefined; + // One-shot user-facing notices (e.g. the managed-memory heads-up before the slow CFN apply). + // Always shown, independent of --progress/--verbose. Clear any active spinner line first so the + // multi-line notice prints cleanly, then resume the spinner frame on the next onProgress tick. + const onNotice = (message: string) => { + if (spinner) { + clearInterval(spinner); + spinner = undefined; + process.stdout.write('\r\x1b[K'); + } + console.log(`\n${message}\n`); + }; + const result = await handleDeploy({ target: options.target!, autoConfirm: options.yes, @@ -113,6 +125,7 @@ async function executeDeploy(options: DeployOptions): Promise { diff: options.diff, onProgress, onResourceEvent, + onNotice, }); if (spinner) { @@ -198,6 +211,7 @@ export const registerDeploy = (program: Command) => { target: cliOptions.target ?? 'default', progress: !cliOptions.json, }; + await handleDeployCLI(options as DeployOptions); } else if (cliOptions.diff) { // Diff-only: use TUI with diff mode diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index 7f01a8dfc..bec8ad94e 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -178,7 +178,7 @@ export const registerDev = (program: Command) => { .option('--exec', 'Execute a shell command in the running dev container (Container agents only) [non-interactive]') .option('--tool ', 'MCP tool name (used with "call-tool" prompt) [non-interactive]') .option('--input ', 'MCP tool arguments as JSON (used with --tool) [non-interactive]') - .option('--skip-deploy', 'Skip automatic resource deployment before starting dev server [preview]') + .option('--skip-deploy', 'Skip automatic resource deployment before starting dev server') .option( '-H, --header
', 'Custom header to forward to the agent (format: "Name: Value", repeatable) [non-interactive]', diff --git a/src/cli/commands/exec/__tests__/command.test.ts b/src/cli/commands/exec/__tests__/command.test.ts index 24b6d1f1d..c0c68ff77 100644 --- a/src/cli/commands/exec/__tests__/command.test.ts +++ b/src/cli/commands/exec/__tests__/command.test.ts @@ -1,6 +1,7 @@ // --------------------------------------------------------------------------- // Telemetry attribute correctness // --------------------------------------------------------------------------- +import { ANSI } from '../../../constants.js'; import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; import { handleExecOneShot, handleShellSession } from '../action.js'; import { registerExec } from '../command.js'; @@ -549,3 +550,63 @@ describe('exec runExecLoop fatal error handling', () => { expect(mockExit).toHaveBeenCalledWith(1); }); }); + +// --------------------------------------------------------------------------- +// Picker renders in the alternate screen (regression: exec used to drop out of +// fullscreen and leave static frames behind in the normal buffer) +// --------------------------------------------------------------------------- + +describe('exec picker alternate-screen handling', () => { + let mockExit: ReturnType; + let writeSpy: ReturnType; + let writes: string[]; + + beforeEach(() => { + mockExit = vi.spyOn(process, 'exit').mockImplementation((_code?: string | number | null) => { + throw new Error(`process.exit(${_code})`); + }); + writes = []; + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + writes.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return true; + }); + }); + + afterEach(() => { + mockExit.mockRestore(); + writeSpy.mockRestore(); + vi.clearAllMocks(); + }); + + it('enters the alt screen for the picker and restores the normal buffer + cursor on select', async () => { + vi.mocked(handleShellSession).mockResolvedValue({ success: true }); + + const { ExecScreen } = await import('../../../tui/screens/exec/index.js'); + vi.mocked(ExecScreen).mockImplementation((_props: unknown) => null as unknown as React.ReactElement); + + // Render the picker, then immediately auto-select so the loop runs once and exits. + const { render } = await import('ink'); + vi.mocked(render).mockImplementation((_element: unknown) => { + const props = (_element as { props: { onSelect: (r: { runtimeArn: string; autoSelected: boolean }) => void } }) + .props; + void Promise.resolve().then(() => + props.onSelect({ runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/r', autoSelected: true }) + ); + return { unmount: vi.fn(), rerender: vi.fn(), clear: vi.fn(), cleanup: vi.fn(), waitUntilExit: vi.fn() }; + }); + + const program = new Command(); + program.exitOverride(); + registerExec(program); + + // The command calls process.exit when the picker loop finishes; the exact code + // is irrelevant here — we only care that the picker entered/restored the alt screen. + await expect(program.parseAsync(['exec', '--it'], { from: 'user' })).rejects.toThrow(/process\.exit/); + + // Entered the alt screen, then restored the normal buffer + cursor afterwards. + expect(writes).toContain(ANSI.enterAltScreen); + expect(writes.some(w => w.includes(ANSI.exitAltScreen))).toBe(true); + expect(writes.some(w => w.includes(ANSI.showCursor))).toBe(true); + expect(writes.indexOf(ANSI.enterAltScreen)).toBeLessThan(writes.findIndex(w => w.includes(ANSI.exitAltScreen))); + }); +}); diff --git a/src/cli/commands/exec/command.tsx b/src/cli/commands/exec/command.tsx index 3a3ee3974..fc54c3116 100644 --- a/src/cli/commands/exec/command.tsx +++ b/src/cli/commands/exec/command.tsx @@ -1,5 +1,5 @@ import { findConfigRoot } from '../../../lib'; -import { COMMAND_DESCRIPTIONS } from '../../constants'; +import { ANSI, COMMAND_DESCRIPTIONS } from '../../constants'; import { getErrorMessage } from '../../errors'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { requireProject, requireTTY } from '../../tui/guards'; @@ -239,6 +239,13 @@ interface PickResult { } function pickAgent(): Promise { + // Render the picker in the alternate screen so it (and any inline error state, + // e.g. missing project config) repaints in place instead of leaking static + // frames into the normal buffer. Restore the normal buffer + cursor on exit; + // the PTY shell session that follows runs in the normal buffer. + process.stdout.write(ANSI.enterAltScreen); + const restore = () => process.stdout.write(ANSI.exitAltScreen + ANSI.showCursor); + return new Promise(resolve => { let resolved = false; @@ -248,13 +255,14 @@ function pickAgent(): Promise { if (resolved) return; resolved = true; unmount(); - process.stdout.write('\x1b[2J\x1b[H'); + restore(); resolve({ runtimeArn: result.runtimeArn, sessionId: result.sessionId, autoSelected: result.autoSelected }); }} onExit={() => { if (!resolved) { resolved = true; unmount(); + restore(); resolve(null); } }} diff --git a/src/cli/commands/export/__tests__/harness-action.test.ts b/src/cli/commands/export/__tests__/harness-action.test.ts new file mode 100644 index 000000000..5823007a6 --- /dev/null +++ b/src/cli/commands/export/__tests__/harness-action.test.ts @@ -0,0 +1,42 @@ +import { CUSTOM_DOCKERFILE_NOTE_CATEGORY } from '../constants'; +import { buildCustomDockerfileNote, buildMissingDockerfileNote } from '../harness-action'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// Custom-Dockerfile export note +// ============================================================================ + +describe('buildCustomDockerfileNote', () => { + it('uses the custom-dockerfile category so EXPORT_NOTES is no longer empty', () => { + const note = buildCustomDockerfileNote('Harness.Dockerfile', 'MyHarnessAgent'); + expect(note.category).toBe(CUSTOM_DOCKERFILE_NOTE_CATEGORY); + }); + + it('references the actual dockerfile name and target agent path', () => { + const note = buildCustomDockerfileNote('Harness.Dockerfile', 'MyHarnessAgent'); + expect(note.message).toContain('Harness.Dockerfile'); + expect(note.message).toContain('app/MyHarnessAgent/Harness.Dockerfile'); + }); + + it('warns that the agent will not run as-is and provides the agent build layer', () => { + const note = buildCustomDockerfileNote('Custom.Dockerfile', 'AgentX'); + expect(note.message).toMatch(/will NOT run as-is/); + // The appended build layer must install deps, copy code, and set a startup command. + expect(note.message).toContain('uv sync --frozen --no-dev'); + expect(note.message).toContain('COPY --chown=bedrock_agentcore:bedrock_agentcore . .'); + expect(note.message).toContain('CMD ["opentelemetry-instrument", "python", "-m", "main"]'); + }); +}); + +// ============================================================================ +// Missing-Dockerfile export note +// ============================================================================ + +describe('buildMissingDockerfileNote', () => { + it('explains the referenced dockerfile is absent and where to create it', () => { + const note = buildMissingDockerfileNote('Harness.Dockerfile', 'MyHarness', 'MyHarnessAgent'); + expect(note.category).toMatch(/Dockerfile not found/); + expect(note.message).toContain('app/MyHarness/'); + expect(note.message).toContain('app/MyHarnessAgent/Harness.Dockerfile'); + }); +}); diff --git a/src/cli/commands/export/__tests__/harness-mapper.test.ts b/src/cli/commands/export/__tests__/harness-mapper.test.ts new file mode 100644 index 000000000..99bfed754 --- /dev/null +++ b/src/cli/commands/export/__tests__/harness-mapper.test.ts @@ -0,0 +1,1038 @@ +import type { HarnessSpec } from '../../../../schema/schemas/primitives/harness'; +import { + ALLOWED_TOOLS_NOTE_CATEGORY, + AWS_SKILLS_NOTE_CATEGORY, + BROWSER_CODZIP_NOTE_CATEGORY, + BROWSER_IAM_POLICY_NOTE_CATEGORY, + CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY, + CONTAINER_URI_ECR_PULL_NOTE_CATEGORY, + CONTAINER_URI_NOTE_CATEGORY, + EXTERNAL_GATEWAY_NOTE_CATEGORY, + GATEWAY_IAM_POLICY_NOTE_CATEGORY, + GIT_SKILLS_CONTAINER_NOTE_CATEGORY, + MCP_HEADER_CREDS_NOTE_CATEGORY, + MEMORY_ARN_NOTE_CATEGORY, + PATH_SKILLS_NOTE_CATEGORY, + S3_SKILLS_IAM_POLICY_NOTE_CATEGORY, +} from '../constants'; +import { mapHarnessToExportConfig } from '../harness-mapper'; +import type { ResolvedHarnessContext } from '../types'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// Test helpers +// ============================================================================ + +function baseSpec(overrides: Partial = {}): HarnessSpec { + return { + name: 'TestHarness', + model: { provider: 'bedrock', modelId: 'global.anthropic.claude-sonnet-4-6' }, + tools: [], + skills: [], + ...overrides, + } as HarnessSpec; +} + +function baseContext( + specOverrides: Partial = {}, + contextOverrides: Partial = {} +): ResolvedHarnessContext { + return { + harnessName: 'TestHarness', + targetAgentName: 'TestAgent', + spec: baseSpec(specOverrides), + systemPrompt: 'You are helpful.', + projectSpec: { name: 'myproject', runtimes: [], memories: [], credentials: [], harnesses: [] } as any, + deployedResources: null, + configBaseDir: '/project/agentcore', + projectRoot: '/project', + exportNotes: [], + region: 'us-east-1', + ...contextOverrides, + }; +} + +function noteCategories(context: ResolvedHarnessContext): string[] { + return context.exportNotes.map(n => n.category); +} + +// ============================================================================ +// CodeZip + container suppression +// ============================================================================ + +describe('CodeZip suppression for container harnesses', () => { + it('throws when containerUri is set and build is CodeZip', () => { + const ctx = baseContext({ containerUri: '123.dkr.ecr.us-east-1.amazonaws.com/img:latest' }); + expect(() => mapHarnessToExportConfig(ctx, 'CodeZip')).toThrow(/containerUri.*requires a Container build/); + }); + + it('throws when dockerfile is set and build is CodeZip', () => { + const ctx = baseContext({ dockerfile: 'Dockerfile.custom' }); + expect(() => mapHarnessToExportConfig(ctx, 'CodeZip')).toThrow(/dockerfile.*requires a Container build/); + }); + + it('succeeds when containerUri is set and build is Container', () => { + const ctx = baseContext({ containerUri: '123.dkr.ecr.us-east-1.amazonaws.com/img:latest' }); + expect(() => mapHarnessToExportConfig(ctx, 'Container')).not.toThrow(); + }); + + it('succeeds when dockerfile is set and build is Container', () => { + const ctx = baseContext({ dockerfile: 'Dockerfile.custom' }); + expect(() => mapHarnessToExportConfig(ctx, 'Container')).not.toThrow(); + }); + + it('includes containerUri note for Container build', () => { + const ctx = baseContext({ containerUri: '123.dkr.ecr.us-east-1.amazonaws.com/img:latest' }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).toContain(CONTAINER_URI_NOTE_CATEGORY); + }); + + it('does not include containerUri note for plain CodeZip harness', () => { + const ctx = baseContext(); + mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).not.toContain(CONTAINER_URI_NOTE_CATEGORY); + }); + + it('includes ECR pull note when base image is a private ECR repository', () => { + const ctx = baseContext({ containerUri: '123456789012.dkr.ecr.us-east-1.amazonaws.com/my-base:latest' }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).toContain(CONTAINER_URI_ECR_PULL_NOTE_CATEGORY); + const note = ctx.exportNotes.find(n => n.category === CONTAINER_URI_ECR_PULL_NOTE_CATEGORY); + // Note carries the resolved ECR repo ARN and a working grantPull snippet. + expect(note?.message).toContain('arn:aws:ecr:us-east-1:123456789012:repository/my-base'); + expect(note?.message).toContain('ContainerBuildProject.getOrCreate(this).role'); + }); + + it('does not include ECR pull note when base image is a public registry', () => { + const ctx = baseContext({ containerUri: 'public.ecr.aws/docker/library/python:3.12-slim' }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).toContain(CONTAINER_URI_NOTE_CATEGORY); + expect(noteCategories(ctx)).not.toContain(CONTAINER_URI_ECR_PULL_NOTE_CATEGORY); + }); +}); + +// ============================================================================ +// Browser tool — CodeZip exclusion + Container inclusion +// ============================================================================ + +describe('browser tool handling', () => { + const browserTool = { type: 'agentcore_browser' as const, name: 'browser' }; + + it('sets hasBrowser=false and emits CodeZip note for CodeZip build', () => { + const ctx = baseContext({ tools: [browserTool] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasBrowser).toBe(false); + expect(noteCategories(ctx)).toContain(BROWSER_CODZIP_NOTE_CATEGORY); + }); + + it('sets hasBrowser=true and emits IAM note for Container build', () => { + const ctx = baseContext({ tools: [browserTool] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.hasBrowser).toBe(true); + expect(noteCategories(ctx)).toContain(BROWSER_IAM_POLICY_NOTE_CATEGORY); + }); + + it('CodeZip note re-export hint uses --name flag', () => { + const ctx = baseContext({ tools: [browserTool] }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + const note = ctx.exportNotes.find(n => n.category === BROWSER_CODZIP_NOTE_CATEGORY)!; + expect(note.message).toContain('--name TestHarness'); + expect(note.message).not.toContain('--harness'); + }); + + it('IAM note uses default browser ARN when no custom browserArn', () => { + const ctx = baseContext({ tools: [browserTool] }); + mapHarnessToExportConfig(ctx, 'Container'); + const note = ctx.exportNotes.find(n => n.category === BROWSER_IAM_POLICY_NOTE_CATEGORY)!; + expect(note.message).toContain(':aws:browser/*'); + }); + + it('IAM note uses custom browserArn when provided', () => { + const ctx = baseContext({ + tools: [ + { + ...browserTool, + config: { + agentCoreBrowser: { browserArn: 'arn:aws:bedrock-agentcore:us-east-1:123:browser-custom/my_browser_id' }, + }, + }, + ], + }); + mapHarnessToExportConfig(ctx, 'Container'); + const note = ctx.exportNotes.find(n => n.category === BROWSER_IAM_POLICY_NOTE_CATEGORY)!; + expect(note.message).toContain('arn:aws:bedrock-agentcore:us-east-1:123:browser-custom/my_browser_id'); + }); +}); + +// ============================================================================ +// Code interpreter tool +// ============================================================================ + +describe('code interpreter tool handling', () => { + const ciTool = { type: 'agentcore_code_interpreter' as const, name: 'code-interpreter' }; + + it('sets hasCodeInterpreter=true for CodeZip build', () => { + const ctx = baseContext({ tools: [ciTool] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasCodeInterpreter).toBe(true); + }); + + it('emits IAM note with default ARN', () => { + const ctx = baseContext({ tools: [ciTool] }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + const note = ctx.exportNotes.find(n => n.category === CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY)!; + expect(note.message).toContain(':aws:code-interpreter/*'); + }); + + it('emits IAM note with custom codeInterpreterArn when provided', () => { + const ctx = baseContext({ + tools: [ + { + ...ciTool, + config: { + agentCoreCodeInterpreter: { + codeInterpreterArn: 'arn:aws:bedrock-agentcore:us-east-1:123:code-interpreter-custom/my_ci_id', + }, + }, + }, + ], + }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + const note = ctx.exportNotes.find(n => n.category === CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY)!; + expect(note.message).toContain('arn:aws:bedrock-agentcore:us-east-1:123:code-interpreter-custom/my_ci_id'); + }); +}); + +// ============================================================================ +// Custom tool identifier extraction (browserIdentifier / codeInterpreterIdentifier) +// ============================================================================ + +describe('custom tool identifier extraction', () => { + it('extracts browserIdentifier from browserArn', () => { + const ctx = baseContext({ + tools: [ + { + type: 'agentcore_browser' as const, + name: 'browser', + config: { + agentCoreBrowser: { browserArn: 'arn:aws:bedrock-agentcore:us-east-1:123:browser-custom/browser_abc123' }, + }, + }, + ], + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.browserIdentifier).toBe('browser_abc123'); + }); + + it('sets browserIdentifier=undefined when no custom browserArn', () => { + const ctx = baseContext({ tools: [{ type: 'agentcore_browser' as const, name: 'browser' }] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.browserIdentifier).toBeUndefined(); + }); + + it('extracts codeInterpreterIdentifier from codeInterpreterArn', () => { + const ctx = baseContext({ + tools: [ + { + type: 'agentcore_code_interpreter' as const, + name: 'ci', + config: { + agentCoreCodeInterpreter: { + codeInterpreterArn: 'arn:aws:bedrock-agentcore:us-east-1:123:code-interpreter-custom/ci_xyz789', + }, + }, + }, + ], + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.codeInterpreterIdentifier).toBe('ci_xyz789'); + }); + + it('sets codeInterpreterIdentifier=undefined when no custom codeInterpreterArn', () => { + const ctx = baseContext({ tools: [{ type: 'agentcore_code_interpreter' as const, name: 'ci' }] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.codeInterpreterIdentifier).toBeUndefined(); + }); +}); + +// ============================================================================ +// allowedTools filtering +// ============================================================================ + +describe('allowedTools filtering', () => { + const browserTool = { type: 'agentcore_browser' as const, name: 'browser' }; + const ciTool = { type: 'agentcore_code_interpreter' as const, name: 'code-interpreter' }; + + it('excludes browser when not in allowedTools', () => { + const ctx = baseContext({ tools: [browserTool, ciTool], allowedTools: ['code-interpreter'] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.hasBrowser).toBe(false); + expect(renderConfig.hasCodeInterpreter).toBe(true); + }); + + it('excludes code interpreter when not in allowedTools', () => { + const ctx = baseContext({ tools: [browserTool, ciTool], allowedTools: ['browser'] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.hasBrowser).toBe(true); + expect(renderConfig.hasCodeInterpreter).toBe(false); + }); + + it('includes all tools when allowedTools is wildcard', () => { + const ctx = baseContext({ tools: [browserTool, ciTool], allowedTools: ['*'] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.hasBrowser).toBe(true); + expect(renderConfig.hasCodeInterpreter).toBe(true); + }); + + it('emits allowedTools note when filter is not wildcard', () => { + const ctx = baseContext({ tools: [browserTool, ciTool], allowedTools: ['code-interpreter'] }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).toContain(ALLOWED_TOOLS_NOTE_CATEGORY); + }); + + it('does not emit allowedTools note when filter is wildcard', () => { + const ctx = baseContext({ tools: [browserTool, ciTool], allowedTools: ['*'] }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).not.toContain(ALLOWED_TOOLS_NOTE_CATEGORY); + }); + + it('does not emit allowedTools note when no allowedTools set (defaults to wildcard)', () => { + const ctx = baseContext({ tools: [browserTool] }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).not.toContain(ALLOWED_TOOLS_NOTE_CATEGORY); + }); + + it('does not emit browser IAM note when browser excluded by allowedTools on Container build', () => { + const ctx = baseContext({ tools: [browserTool, ciTool], allowedTools: ['code-interpreter'] }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).not.toContain(BROWSER_IAM_POLICY_NOTE_CATEGORY); + }); + + it('does not emit code interpreter IAM note when CI excluded by allowedTools', () => { + const ctx = baseContext({ tools: [browserTool, ciTool], allowedTools: ['browser'] }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).not.toContain(CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY); + }); +}); + +// ============================================================================ +// Truncation config translation +// ============================================================================ + +describe('truncation config translation', () => { + it('translates sliding_window messagesCount to window_size', () => { + const ctx = baseContext({ + truncation: { strategy: 'sliding_window', config: { slidingWindow: { messagesCount: 4 } } }, + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.truncationStrategy).toBe('sliding_window'); + expect(renderConfig.truncationConfig).toEqual({ window_size: 4 }); + }); + + it('returns undefined truncationConfig when no messagesCount', () => { + const ctx = baseContext({ truncation: { strategy: 'sliding_window' } }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.truncationStrategy).toBe('sliding_window'); + expect(renderConfig.truncationConfig).toBeUndefined(); + }); + + it('translates all summarization fields to snake_case', () => { + const ctx = baseContext({ + truncation: { + strategy: 'summarization', + config: { + summarization: { + summaryRatio: 0.3, + preserveRecentMessages: 2, + summarizationSystemPrompt: 'Be concise.', + }, + }, + }, + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.truncationStrategy).toBe('summarization'); + expect(renderConfig.truncationConfig).toEqual({ + summary_ratio: 0.3, + preserve_recent_messages: 2, + summarization_system_prompt: 'Be concise.', + }); + }); + + it('translates partial summarization fields', () => { + const ctx = baseContext({ + truncation: { strategy: 'summarization', config: { summarization: { summaryRatio: 0.5 } } }, + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.truncationConfig).toEqual({ summary_ratio: 0.5 }); + }); + + it('returns undefined truncationConfig when summarization has no fields', () => { + const ctx = baseContext({ truncation: { strategy: 'summarization' } }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.truncationConfig).toBeUndefined(); + }); + + it('returns undefined truncationStrategy when no truncation configured', () => { + const ctx = baseContext(); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.truncationStrategy).toBeUndefined(); + expect(renderConfig.truncationConfig).toBeUndefined(); + }); +}); + +// ============================================================================ +// Build type auto-detection +// ============================================================================ + +describe('build type auto-detection', () => { + it('defaults to CodeZip when no override and no container fields', () => { + const ctx = baseContext(); + const { renderConfig } = mapHarnessToExportConfig(ctx); + expect(renderConfig.buildType).toBe('CodeZip'); + }); + + it('defaults to Container when containerUri is present', () => { + const ctx = baseContext({ containerUri: '123.dkr.ecr.us-east-1.amazonaws.com/img:latest' }); + const { renderConfig } = mapHarnessToExportConfig(ctx); + expect(renderConfig.buildType).toBe('Container'); + }); + + it('defaults to Container when dockerfile is present', () => { + const ctx = baseContext({ dockerfile: 'Dockerfile' }); + const { renderConfig } = mapHarnessToExportConfig(ctx); + expect(renderConfig.buildType).toBe('Container'); + }); + + it('override takes precedence over spec fields', () => { + const ctx = baseContext({ containerUri: '123.dkr.ecr.us-east-1.amazonaws.com/img:latest' }); + // Container override — no throw since it matches the spec + const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.buildType).toBe('Container'); + }); +}); + +// ============================================================================ +// Skills notes +// ============================================================================ + +describe('skills notes', () => { + it('emits path skills note when path skills present and build is CodeZip', () => { + const ctx = baseContext({ skills: [{ path: 'skills/my_skill' }] }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).toContain(PATH_SKILLS_NOTE_CATEGORY); + }); + + it('does not emit path skills note for Container build', () => { + const ctx = baseContext({ skills: [{ path: 'skills/my_skill' }] }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).not.toContain(PATH_SKILLS_NOTE_CATEGORY); + }); + + it('emits git skills note for Container build', () => { + const ctx = baseContext({ skills: [{ gitUrl: 'https://github.com/org/repo' }] }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).toContain(GIT_SKILLS_CONTAINER_NOTE_CATEGORY); + }); + + it('does not emit git skills note for CodeZip build', () => { + const ctx = baseContext({ skills: [{ gitUrl: 'https://github.com/org/repo' }] }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).not.toContain(GIT_SKILLS_CONTAINER_NOTE_CATEGORY); + }); + + it('emits s3 skills IAM note with GetObject + ListBucket ARNs for CodeZip', () => { + const ctx = baseContext({ skills: [{ s3Uri: 's3://my-bucket/skills/weather/' }] }, { targetAgentName: 'MyAgent' }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).toContain(S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); + const note = ctx.exportNotes.find(n => n.category === S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); + // Object-level read scoped to the prefix + expect(note?.message).toContain("actions: ['s3:GetObject']"); + expect(note?.message).toContain("'arn:aws:s3:::my-bucket/skills/weather/*'"); + // List scoped to the bucket + expect(note?.message).toContain("actions: ['s3:ListBucket']"); + expect(note?.message).toContain("'arn:aws:s3:::my-bucket'"); + // Snippet targets the renamed agent + expect(note?.message).toContain("this.application.environments.get('MyAgent')"); + }); + + it('emits s3 skills IAM note for Container builds too (independent of build type)', () => { + const ctx = baseContext({ skills: [{ s3Uri: 's3://my-bucket/skills/weather/' }] }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).toContain(S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); + }); + + it('uses bucket-root object ARN when the s3 URI has no prefix', () => { + const ctx = baseContext({ skills: [{ s3Uri: 's3://my-bucket' }] }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + const note = ctx.exportNotes.find(n => n.category === S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); + expect(note?.message).toContain("'arn:aws:s3:::my-bucket/*'"); + expect(note?.message).toContain("'arn:aws:s3:::my-bucket'"); + }); + + it('deduplicates ARNs across multiple s3 skills in the same bucket', () => { + const ctx = baseContext({ + skills: [{ s3Uri: 's3://shared/a/' }, { s3Uri: 's3://shared/b/' }], + }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + const note = ctx.exportNotes.find(n => n.category === S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); + // One ListBucket resource for the shared bucket, two distinct object ARNs + expect(note?.message).toContain("'arn:aws:s3:::shared/a/*'"); + expect(note?.message).toContain("'arn:aws:s3:::shared/b/*'"); + expect(note?.message.match(/arn:aws:s3:::shared'/g)?.length).toBe(1); + }); + + it('uses the GovCloud partition prefix for gov regions', () => { + const ctx = baseContext({ skills: [{ s3Uri: 's3://gov-bucket/skills/' }] }, { region: 'us-gov-west-1' }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + const note = ctx.exportNotes.find(n => n.category === S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); + expect(note?.message).toContain("'arn:aws-us-gov:s3:::gov-bucket/skills/*'"); + }); + + it('does not emit s3 skills IAM note when there are no s3 skills', () => { + const ctx = baseContext({ skills: [{ path: 'skills/local' }] }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).not.toContain(S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); + }); +}); + +// ============================================================================ +// skills render config mapping (new flat schema shape) +// ============================================================================ + +describe('skills render config mapping', () => { + it('maps path, s3, and git skills into the render config', () => { + const ctx = baseContext({ + skills: [ + { path: 'skills/local' }, + { s3Uri: 's3://bucket/skills/xlsx/' }, + { + gitUrl: 'https://github.com/org/repo', + path: 'skills/x', + auth: { credentialName: 'MyGitCred', username: 'me' }, + }, + ], + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.pathSkills).toEqual(['skills/local']); + expect(renderConfig.s3Skills).toEqual(['s3://bucket/skills/xlsx/']); + expect(renderConfig.gitSkills).toEqual([ + { url: 'https://github.com/org/repo', path: 'skills/x', credentialArn: 'MyGitCred', username: 'me' }, + ]); + expect(renderConfig.hasFetchedSkills).toBe(true); + }); + + it('does not include AWS skills in path/s3/git render config arrays and emits export note', () => { + const ctx = baseContext({ + skills: [{ path: 'skills/local' }, { awsSkills: { paths: ['core-skills/*'] } }], + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.pathSkills).toEqual(['skills/local']); + expect(renderConfig.s3Skills).toEqual([]); + expect(renderConfig.gitSkills).toEqual([]); + expect(renderConfig.hasFetchedSkills).toBe(false); + expect(noteCategories(ctx)).toContain(AWS_SKILLS_NOTE_CATEGORY); + const note = ctx.exportNotes.find(n => n.category === AWS_SKILLS_NOTE_CATEGORY); + expect(note?.message).toContain('core-skills/*'); + expect(note?.message).toContain('https://github.com/aws/agent-toolkit-for-aws/tree/main/skills'); + }); + + it('does not emit AWS skills note when no AWS skills are present', () => { + const ctx = baseContext({ + skills: [{ path: 'skills/local' }, { s3Uri: 's3://bucket/skill' }], + }); + mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).not.toContain(AWS_SKILLS_NOTE_CATEGORY); + }); +}); + +// ============================================================================ +// model provider +// ============================================================================ + +describe('resolveModelProvider', () => { + it('rejects the lite_llm provider (unsupported by Strands export)', () => { + const ctx = baseContext({ model: { provider: 'lite_llm', modelId: 'some-model' } as never }); + expect(() => mapHarnessToExportConfig(ctx, 'CodeZip')).toThrow(/lite_llm.*does not support/); + }); +}); + +// ============================================================================ +// extractToolIdentifier edge cases +// ============================================================================ + +describe('extractToolIdentifier edge cases', () => { + it('returns undefined when ARN has no slash', () => { + const ctx = baseContext({ + tools: [ + { + type: 'agentcore_browser' as const, + name: 'browser', + config: { agentCoreBrowser: { browserArn: 'arn:aws:bedrock-agentcore:us-east-1:123:noslash' } }, + }, + ], + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.browserIdentifier).toBeUndefined(); + }); + + it('returns undefined when browserArn is empty string', () => { + const ctx = baseContext({ + tools: [ + { + type: 'agentcore_browser' as const, + name: 'browser', + config: { agentCoreBrowser: { browserArn: '' } }, + }, + ], + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.browserIdentifier).toBeUndefined(); + }); +}); + +// ============================================================================ +// resolveTruncationConfig edge cases +// ============================================================================ + +describe('resolveTruncationConfig edge cases', () => { + it('returns undefined when sliding_window config has no slidingWindow key', () => { + const ctx = baseContext({ + truncation: { strategy: 'sliding_window', config: {} as any }, + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.truncationConfig).toBeUndefined(); + }); + + it('returns undefined for unknown strategy', () => { + const ctx = baseContext({ + truncation: { strategy: 'sliding_window', config: { unknownKey: { foo: 1 } } as any }, + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.truncationConfig).toBeUndefined(); + }); +}); + +// ============================================================================ +// resolveMemoryProviders +// ============================================================================ + +describe('resolveMemoryProviders', () => { + it('returns empty providers when no memory configured', () => { + const ctx = baseContext(); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasMemory).toBe(false); + expect(renderConfig.memoryProviders).toHaveLength(0); + }); + + it('resolves same-project memory by name with env var', () => { + const ctx = baseContext( + { memory: { mode: 'existing', name: 'MyMemory' } }, + { + projectSpec: { + name: 'myproject', + runtimes: [], + memories: [{ name: 'MyMemory', strategies: [] }], + credentials: [], + harnesses: [], + } as any, + } + ); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasMemory).toBe(true); + expect(renderConfig.memoryProviders).toHaveLength(1); + expect(renderConfig.memoryProviders?.at(0)!.name).toBe('MyMemory'); + expect(renderConfig.memoryProviders?.at(0)!.envVarName).toBe('MEMORY_MYMEMORY_ID'); + }); + + it('resolves memory by ARN via deployed state match', () => { + const memArn = 'arn:aws:bedrock-agentcore:us-east-1:123:memory/abc123'; + const ctx = baseContext( + { memory: { mode: 'existing', arn: memArn } }, + { + deployedResources: { + memories: { DeployedMem: { memoryArn: memArn } }, + } as any, + projectSpec: { + name: 'myproject', + runtimes: [], + memories: [{ name: 'DeployedMem', strategies: [] }], + credentials: [], + harnesses: [], + } as any, + } + ); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasMemory).toBe(true); + expect(renderConfig.memoryProviders?.at(0)!.name).toBe('DeployedMem'); + expect(renderConfig.memoryProviders?.at(0)!.envVarName).toBe('MEMORY_DEPLOYEDMEM_ID'); + }); + + it('falls back to MEMORY_ARN env var for external memory ARN and emits note', () => { + const ctx = baseContext( + { memory: { mode: 'existing', arn: 'arn:aws:bedrock-agentcore:us-east-1:999:memory/external' } }, + { deployedResources: null } + ); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasMemory).toBe(true); + expect(renderConfig.memoryProviders?.at(0)!.envVarName).toBe('MEMORY_ARN'); + expect(noteCategories(ctx)).toContain(MEMORY_ARN_NOTE_CATEGORY); + }); +}); + +// ============================================================================ +// model ID propagation +// ============================================================================ + +describe('model ID propagation to renderConfig', () => { + it('propagates the bedrock model ID', () => { + const ctx = baseContext({ model: { provider: 'bedrock', modelId: 'anthropic.claude-3' } }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.modelId).toBe('anthropic.claude-3'); + }); + + it('propagates the OpenAI model ID (does not hardcode gpt-4.1)', () => { + const ctx = baseContext({ + model: { + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret/openai', + }, + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.modelId).toBe('gpt-4o'); + }); + + it('propagates the Gemini model ID (does not hardcode gemini-2.5-flash)', () => { + const ctx = baseContext({ + model: { + provider: 'gemini', + modelId: 'gemini-1.5-pro', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret/gemini', + }, + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.modelId).toBe('gemini-1.5-pro'); + }); +}); + +// ============================================================================ +// resolveIdentityProvider +// ============================================================================ + +describe('resolveIdentityProvider', () => { + it('returns no identity provider for bedrock model', () => { + const ctx = baseContext({ model: { provider: 'bedrock', modelId: 'anthropic.claude-3' } }); + const { renderConfig, credentialEntry } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasIdentity).toBe(false); + expect(renderConfig.identityProviders).toHaveLength(0); + expect(credentialEntry).toBeNull(); + }); + + it('creates new credential entry for OpenAI model with apiKeyArn', () => { + const ctx = baseContext({ + model: { + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret/openai', + }, + }); + const { renderConfig, credentialEntry } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasIdentity).toBe(true); + expect(renderConfig.identityProviders).toHaveLength(1); + expect(credentialEntry).not.toBeNull(); + expect(credentialEntry!.name).toContain('OpenAI'); + }); + + it('creates new credential entry for Gemini model', () => { + const ctx = baseContext({ + model: { + provider: 'gemini', + modelId: 'gemini-1.5-pro', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret/gemini', + }, + }); + const { renderConfig, credentialEntry } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasIdentity).toBe(true); + expect(credentialEntry!.name).toContain('Gemini'); + }); + + it('reuses existing credential when apiKeyArn matches project credential', () => { + const apiKeyArn = 'arn:aws:secretsmanager:us-east-1:123:secret/openai'; + const ctx = baseContext( + { model: { provider: 'open_ai', modelId: 'gpt-4o', apiKeyArn } }, + { + projectSpec: { + name: 'myproject', + runtimes: [], + memories: [], + credentials: [{ name: 'ExistingOpenAI', authorizerType: 'ApiKeyCredentialProvider', apiKeyArn }], + harnesses: [], + } as any, + } + ); + const { renderConfig, credentialEntry } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.identityProviders?.at(0)!.name).toBe('ExistingOpenAI'); + expect(credentialEntry).toBeNull(); // already in project, no new entry + }); + + it('returns no identity when non-bedrock model has no apiKeyArn', () => { + const ctx = baseContext({ model: { provider: 'open_ai', modelId: 'gpt-4o' } }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasIdentity).toBe(false); + }); +}); + +// ============================================================================ +// resolveGatewayProviders +// ============================================================================ + +describe('resolveGatewayProviders', () => { + const gatewayArn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-abc123'; + const gatewayTool = { + type: 'agentcore_gateway' as const, + name: 'my-gateway', + config: { agentCoreGateway: { gatewayArn } }, + }; + + it('returns no gateway providers when no gateway tools', () => { + const ctx = baseContext(); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasGateway).toBe(false); + expect(renderConfig.gatewayProviders).toHaveLength(0); + }); + + it('resolves same-project gateway via deployed state without IAM note', () => { + const ctx = baseContext( + { tools: [gatewayTool] }, + { + deployedResources: { + mcp: { gateways: { MyGateway: { gatewayArn } } }, + } as any, + projectSpec: { + name: 'myproject', + runtimes: [], + memories: [], + credentials: [], + harnesses: [], + agentCoreGateways: [{ name: 'MyGateway', authorizerType: 'AWS_IAM' }], + } as any, + } + ); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasGateway).toBe(true); + expect(renderConfig.gatewayProviders?.at(0)!.name).toBe('MyGateway'); + expect(renderConfig.gatewayProviders?.at(0)!.authType).toBe('AWS_IAM'); + expect(noteCategories(ctx)).not.toContain(GATEWAY_IAM_POLICY_NOTE_CATEGORY); + }); + + it('resolves same-project CUSTOM_JWT gateway with discoveryUrl and scopes', () => { + const ctx = baseContext( + { tools: [gatewayTool] }, + { + deployedResources: { + mcp: { gateways: { MyGateway: { gatewayArn } } }, + } as any, + projectSpec: { + name: 'myproject', + runtimes: [], + memories: [], + credentials: [], + harnesses: [], + agentCoreGateways: [ + { + name: 'MyGateway', + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration', + allowedScopes: ['read', 'write'], + }, + }, + }, + ], + } as any, + } + ); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + const provider = renderConfig.gatewayProviders.find(() => true); + expect(provider?.authType).toBe('CUSTOM_JWT'); + expect(provider?.discoveryUrl).toBe('https://auth.example.com/.well-known/openid-configuration'); + expect(provider?.scopes).toBe('read write'); + expect(noteCategories(ctx)).not.toContain(GATEWAY_IAM_POLICY_NOTE_CATEGORY); + }); + + it('hardcodes URL for external gateway and emits external note + IAM note', () => { + const ctx = baseContext({ tools: [gatewayTool] }, { deployedResources: null }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasGateway).toBe(true); + const provider = renderConfig.gatewayProviders.find(() => true); + expect(provider?.hardcodedUrl).toContain('gateway.bedrock-agentcore'); + expect(noteCategories(ctx)).toContain(EXTERNAL_GATEWAY_NOTE_CATEGORY); + expect(noteCategories(ctx)).toContain(GATEWAY_IAM_POLICY_NOTE_CATEGORY); + }); + + it('excludes gateway tool filtered out by allowedTools', () => { + const ctx = baseContext({ tools: [gatewayTool], allowedTools: ['other-tool'] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasGateway).toBe(false); + }); +}); + +// ============================================================================ +// resolveRemoteMcpTools +// ============================================================================ + +describe('resolveRemoteMcpTools', () => { + it('returns remote MCP tool with URL', () => { + const ctx = baseContext({ + tools: [ + { + type: 'remote_mcp' as const, + name: 'my-mcp', + config: { remoteMcp: { url: 'https://mcp.example.com/sse' } }, + }, + ], + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.remoteMcpTools).toHaveLength(1); + expect(renderConfig.remoteMcpTools?.at(0)!.url).toBe('https://mcp.example.com/sse'); + expect(renderConfig.remoteMcpTools?.at(0)!.name).toBe('my-mcp'); + }); + + it('generates credential entries for MCP tools with headers', () => { + const ctx = baseContext({ + tools: [ + { + type: 'remote_mcp' as const, + name: 'my-mcp', + config: { + remoteMcp: { + url: 'https://mcp.example.com/sse', + headers: { Authorization: 'Bearer secret-token' }, + }, + }, + }, + ], + }); + const { mcpCredentialEntries } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(mcpCredentialEntries).toHaveLength(1); + expect(mcpCredentialEntries.at(0)!.value).toBe('Bearer secret-token'); + expect(mcpCredentialEntries.at(0)!.credential.name).toContain('Mcp'); + expect(noteCategories(ctx)).toContain(MCP_HEADER_CREDS_NOTE_CATEGORY); + }); + + it('returns no credential entries for MCP tools without headers', () => { + const ctx = baseContext({ + tools: [ + { + type: 'remote_mcp' as const, + name: 'my-mcp', + config: { remoteMcp: { url: 'https://mcp.example.com/sse' } }, + }, + ], + }); + const { mcpCredentialEntries } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(mcpCredentialEntries).toHaveLength(0); + expect(noteCategories(ctx)).not.toContain(MCP_HEADER_CREDS_NOTE_CATEGORY); + }); + + it('excludes remote MCP tool filtered by allowedTools', () => { + const ctx = baseContext({ + tools: [ + { + type: 'remote_mcp' as const, + name: 'my-mcp', + config: { remoteMcp: { url: 'https://mcp.example.com/sse' } }, + }, + ], + allowedTools: ['other-tool'], + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.remoteMcpTools).toHaveLength(0); + }); +}); + +// ============================================================================ +// resolveInlineFunctionTools +// ============================================================================ + +describe('resolveInlineFunctionTools', () => { + const inlineTool = { + type: 'inline_function' as const, + name: 'my_tool', + config: { + inlineFunction: { + description: 'Does something', + inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + }, + }, + }; + + it('includes inline function tool in renderConfig', () => { + const ctx = baseContext({ tools: [inlineTool] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.inlineFunctionTools).toHaveLength(1); + expect(renderConfig.inlineFunctionTools?.at(0)!.name).toBe('my_tool'); + expect(renderConfig.inlineFunctionTools?.at(0)!.description).toBe('Does something'); + }); + + it('excludes inline tool filtered by allowedTools', () => { + const ctx = baseContext({ tools: [inlineTool], allowedTools: ['other-tool'] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.inlineFunctionTools).toHaveLength(0); + }); + + it('returns empty array when no inline tools', () => { + const ctx = baseContext(); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.inlineFunctionTools).toHaveLength(0); + }); +}); + +// ============================================================================ +// isBuiltinIncluded (shell / file_operations) +// ============================================================================ + +describe('isBuiltinIncluded (shell / file_operations)', () => { + it('includes shell and file_operations when allowedTools is wildcard', () => { + const ctx = baseContext({ allowedTools: ['*'] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasShell).toBe(true); + expect(renderConfig.hasFileOperations).toBe(true); + }); + + it('includes shell and file_operations when allowedTools is unset (defaults to wildcard)', () => { + const ctx = baseContext(); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasShell).toBe(true); + expect(renderConfig.hasFileOperations).toBe(true); + }); + + it('includes shell via @builtin pattern', () => { + const ctx = baseContext({ allowedTools: ['@builtin'] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasShell).toBe(true); + expect(renderConfig.hasFileOperations).toBe(true); + }); + + it('includes shell via @builtin/shell pattern', () => { + const ctx = baseContext({ allowedTools: ['@builtin/shell'] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasShell).toBe(true); + expect(renderConfig.hasFileOperations).toBe(false); + }); + + it('excludes both builtins when allowedTools only lists non-builtin tools', () => { + const ctx = baseContext({ allowedTools: ['some-tool'] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasShell).toBe(false); + expect(renderConfig.hasFileOperations).toBe(false); + }); + + it('plain "shell" name does not match the builtin/shell builtin', () => { + // Only @builtin or @builtin/shell patterns match builtins, not plain tool names + const ctx = baseContext({ allowedTools: ['shell'] }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasShell).toBe(false); + }); +}); diff --git a/src/cli/commands/export/constants.ts b/src/cli/commands/export/constants.ts new file mode 100644 index 000000000..d06bcd300 --- /dev/null +++ b/src/cli/commands/export/constants.ts @@ -0,0 +1,19 @@ +export const EXPORT_NOTES_FILENAME = 'EXPORT_NOTES.md'; + +export const DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant.'; + +export const EXTERNAL_GATEWAY_NOTE_CATEGORY = 'External gateway ARNs hardcoded'; +export const MEMORY_ARN_NOTE_CATEGORY = 'Memory ARN requires IAM policy'; +export const CONTAINER_URI_NOTE_CATEGORY = 'containerUri: verify Python in base image'; +export const CUSTOM_DOCKERFILE_NOTE_CATEGORY = 'Custom harness Dockerfile needs the agent build layer'; +export const CONTAINER_URI_ECR_PULL_NOTE_CATEGORY = 'containerUri base image requires ECR pull permission'; +export const ALLOWED_TOOLS_NOTE_CATEGORY = 'allowedTools: per-invocation overrides dropped'; +export const PATH_SKILLS_NOTE_CATEGORY = 'path skills require container filesystem'; +export const MCP_HEADER_CREDS_NOTE_CATEGORY = 'MCP tool header credentials'; +export const GIT_SKILLS_CONTAINER_NOTE_CATEGORY = 'git skills require git in container image'; +export const GATEWAY_IAM_POLICY_NOTE_CATEGORY = 'Gateway requires InvokeGateway IAM permission'; +export const BROWSER_IAM_POLICY_NOTE_CATEGORY = 'Browser tool requires IAM permissions'; +export const BROWSER_CODZIP_NOTE_CATEGORY = 'Browser tool requires Container build — excluded from CodeZip export'; +export const CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY = 'Code interpreter tool requires IAM permissions'; +export const AWS_SKILLS_NOTE_CATEGORY = 'AWS skills omitted — not available outside managed harness'; +export const S3_SKILLS_IAM_POLICY_NOTE_CATEGORY = 'S3 skills require S3 read IAM permission'; diff --git a/src/cli/commands/export/harness-action.ts b/src/cli/commands/export/harness-action.ts new file mode 100644 index 000000000..d95acdea9 --- /dev/null +++ b/src/cli/commands/export/harness-action.ts @@ -0,0 +1,384 @@ +import { AgentAlreadyExistsError, ConfigIO, setEnvVar } from '../../../lib'; +import { ExportHarnessError, ValidationError } from '../../../lib/errors/types'; +import { AgentNameSchema } from '../../../schema'; +import type { AgentEnvSpec, BuildType, Credential } from '../../../schema'; +import { getErrorMessage } from '../../errors'; +import type { AttributeRecorder } from '../../telemetry/cli-command-run.js'; +import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; +import type { CommandAttrs } from '../../telemetry/schemas/command-run.js'; +import { + BuildType as TelemetryBuildType, + ModelProvider as TelemetryModelProvider, + standardize, +} from '../../telemetry/schemas/common-shapes.js'; +import { StrandsRenderer } from '../../templates/StrandsRenderer'; +import { CUSTOM_DOCKERFILE_NOTE_CATEGORY, EXPORT_NOTES_FILENAME } from './constants'; +import { mapHarnessToExportConfig } from './harness-mapper'; +import { resolveHarnessContext } from './harness-resolver'; +import type { ExportHarnessOptions, ExportNote, ResolvedHarnessContext } from './types'; +import { execSync } from 'node:child_process'; +import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +export interface ExportHarnessProgress { + onProgress?: (message: string) => void; +} + +export async function handleExportHarness( + options: ExportHarnessOptions, + progress?: ExportHarnessProgress +): Promise< + { success: true; agentName: string; agentPath: string; notesPath: string } | { success: false; error: Error } +> { + const log = (msg: string) => progress?.onProgress?.(msg); + + return withCommandRunTelemetry( + 'export.harness', + {} as CommandAttrs<'export.harness'>, + async (recorder: AttributeRecorder>) => { + const harnessName = options.name; + if (!harnessName) { + return { success: false as const, error: new ValidationError('--name is required in non-interactive mode') }; + } + + const targetAgentName = options.targetAgentName ?? `${harnessName}Agent`; + const parsedAgentName = AgentNameSchema.safeParse(targetAgentName); + if (!parsedAgentName.success) { + return { + success: false as const, + error: new ValidationError( + `Invalid --target-agent-name "${targetAgentName}": ${parsedAgentName.error.issues[0]?.message ?? 'invalid name'}` + ), + }; + } + + const buildOverride = options.build as BuildType | undefined; + + const VALID_BUILD_TYPES = new Set(['CodeZip', 'Container']); + if (buildOverride && !VALID_BUILD_TYPES.has(buildOverride)) { + return { + success: false as const, + error: new ValidationError(`Invalid --build value "${buildOverride}". Expected CodeZip or Container.`), + }; + } + + // 1. Resolve all on-disk inputs + log('Reading harness configuration'); + let context: Awaited>; + try { + context = await resolveHarnessContext(harnessName, targetAgentName); + } catch (err) { + return { success: false as const, error: err instanceof Error ? err : new Error(String(err)) }; + } + + // 2. Map harness spec to render config + agent env spec + log('Mapping to Strands template config'); + const { renderConfig, agentEnvSpec, credentialEntry, mcpCredentialEntries } = mapHarnessToExportConfig( + context, + buildOverride + ); + + // The target directory is guaranteed not to pre-exist (resolveHarnessContext throws if it + // does), so anything written below is created by this export. Remove it on failure to avoid + // leaving an orphan directory with no matching agentcore.json entry. + const agentDir = join(context.projectRoot, 'app', targetAgentName); + const cleanupAgentDir = () => { + try { + rmSync(agentDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup — ignore failures + } + }; + + // 3. Copy Dockerfile + supporting harness directories (e.g. path_skill/, assets/) + // Harness files that are NOT copied: harness.json, system-prompt.md (regenerated by export) + const HARNESS_SKIP_FILES = new Set(['harness.json', 'system-prompt.md']); + if (context.spec.dockerfile) { + const harnessDir = join(context.projectRoot, 'app', harnessName); + const dockerfileSrc = join(harnessDir, context.spec.dockerfile); + if (existsSync(dockerfileSrc)) { + mkdirSync(agentDir, { recursive: true }); + copyFileSync(dockerfileSrc, join(agentDir, context.spec.dockerfile)); + context.exportNotes.push(buildCustomDockerfileNote(context.spec.dockerfile, targetAgentName)); + } else { + context.exportNotes.push(buildMissingDockerfileNote(context.spec.dockerfile, harnessName, targetAgentName)); + } + // Copy all non-file entries (directories) and non-skipped files from the harness dir + if (existsSync(harnessDir)) { + cpSync(harnessDir, agentDir, { + recursive: true, + filter: src => { + const name = basename(src); + return !HARNESS_SKIP_FILES.has(name); + }, + }); + } + } + + // 4. Generate Dockerfile stub for containerUri + if (context.spec.containerUri && renderConfig.buildType === 'Container') { + mkdirSync(agentDir, { recursive: true }); + writeDockerfileStub(agentDir, context.spec.containerUri); + } + + // 5. Render Strands agent code + log('Rendering agent code'); + try { + const renderer = new StrandsRenderer(renderConfig); + await renderer.render({ outputDir: context.projectRoot }); + } catch (err) { + cleanupAgentDir(); + return { + success: false as const, + error: new ExportHarnessError( + `Failed to render agent code for "${targetAgentName}": ${getErrorMessage(err)}`, + { + cause: err instanceof Error ? err : undefined, + } + ), + }; + } + + // 5b. Generate uv.lock for Container builds (required by the Dockerfile's uv sync step) + if (renderConfig.buildType === 'Container') { + log('Generating uv.lock for container build'); + try { + execSync('uv lock', { cwd: agentDir, stdio: 'pipe' }); + } catch { + // uv not installed or failed — add a note and continue; user can run manually + context.exportNotes.push({ + category: 'uv.lock missing — run `uv lock` before deploying', + message: + `The container Dockerfile requires a uv.lock file. Run \`uv lock\` in ` + + `app/${targetAgentName}/ before running \`agentcore deploy\`.`, + }); + } + } + + // 6. Write agent to agentcore.json + log('Updating agentcore.json'); + try { + await writeExportedAgentToProject(agentEnvSpec, context, credentialEntry, mcpCredentialEntries); + } catch (err) { + cleanupAgentDir(); + return { success: false as const, error: err instanceof Error ? err : new Error(String(err)) }; + } + + // 6c. Write MCP header credential values to .env.local for local development + for (const { envVarName, value } of mcpCredentialEntries) { + await setEnvVar(envVarName, value, context.configBaseDir); + } + + // 6b. Warn if no deploy targets are configured + const configIO = new ConfigIO({ baseDir: context.configBaseDir }); + const targets = await configIO.readAWSDeploymentTargets().catch(() => []); + if (targets.length === 0) { + context.exportNotes.push({ + category: 'No AWS deployment target configured', + message: + 'aws-targets.json is empty — running `agentcore deploy` will fail with "Target \\"default\\" not found". ' + + 'Add a deployment target first:\n\n' + + ' agentcore deploy (interactive mode will prompt for account/region)\n\n' + + 'Or edit agentcore/aws-targets.json manually:\n\n' + + ' [{ "name": "default", "account": "", "region": "" }]', + }); + } + + // 7. Write EXPORT_NOTES.md + log('Writing EXPORT_NOTES.md'); + writeExportNotes(context.exportNotes, harnessName, targetAgentName, agentDir); + + // Record telemetry attrs after all work is done + recorder.set({ + build_type: standardize(TelemetryBuildType, renderConfig.buildType ?? 'CodeZip'), + model_provider: standardize(TelemetryModelProvider, renderConfig.modelProvider), + has_memory: renderConfig.hasMemory, + has_gateway: renderConfig.hasGateway, + has_container: renderConfig.buildType === 'Container', + has_execution_limits: !!renderConfig.hasExecutionLimits, + notes_count: context.exportNotes.length, + }); + + return { + success: true as const, + agentName: targetAgentName, + agentPath: agentDir, + notesPath: join(agentDir, EXPORT_NOTES_FILENAME), + }; + } + ); +} + +// ============================================================================ +// Write agent entry to agentcore.json +// ============================================================================ + +async function writeExportedAgentToProject( + agentEnvSpec: AgentEnvSpec, + context: ResolvedHarnessContext, + credentialEntry: Credential | null, + mcpCredentialEntries: { credential: Credential }[] +): Promise { + const configIO = new ConfigIO({ baseDir: context.configBaseDir }); + const project = await configIO.readProjectSpec(); + + if (project.runtimes.some(r => r.name === agentEnvSpec.name)) { + throw new AgentAlreadyExistsError(agentEnvSpec.name); + } + + project.runtimes.push(agentEnvSpec); + + if (credentialEntry && !project.credentials.some(c => c.name === credentialEntry.name)) { + project.credentials.push(credentialEntry); + } + + for (const { credential } of mcpCredentialEntries) { + if (!project.credentials.some(c => c.name === credential.name)) { + project.credentials.push(credential); + } + } + + await configIO.writeProjectSpec(project); +} + +// ============================================================================ +// Dockerfile export notes +// ============================================================================ + +/** + * Note emitted when a harness with a custom Dockerfile is exported. + * + * The harness Dockerfile describes an *execution environment* — the harness runtime overrides its + * ENTRYPOINT/CMD and supplies the agent. An exported agent, by contrast, is the entrypoint and must + * build/install its own deps and launch main.py. We cannot safely rewrite an arbitrary user + * Dockerfile (custom base image, WORKDIR, USER, apt packages), so we preserve it as-is and tell the + * user to append the agent build layer. + */ +export function buildCustomDockerfileNote(dockerfile: string, targetAgentName: string): ExportNote { + return { + category: CUSTOM_DOCKERFILE_NOTE_CATEGORY, + message: + `The harness used a custom Dockerfile ("${dockerfile}") that describes its execution ` + + `environment. It has been copied to app/${targetAgentName}/${dockerfile} unchanged, but ` + + `the exported agent will NOT run as-is: a harness Dockerfile has no dependency install, code copy, or ` + + `startup command (the harness runtime supplied those). Add the Strands agent build layer to the end ` + + `of app/${targetAgentName}/${dockerfile} before \`agentcore deploy\` ` + + `(adjust if your base image is not Python 3.12+/uv, or already sets WORKDIR/USER):\n\n` + + ` WORKDIR /app\n` + + ` RUN pip install --no-cache-dir uv\n` + + ` COPY pyproject.toml uv.lock ./\n` + + ` RUN uv sync --frozen --no-dev --no-install-project\n` + + ` COPY --chown=bedrock_agentcore:bedrock_agentcore . .\n` + + ` RUN uv sync --frozen --no-dev\n` + + ` USER bedrock_agentcore\n` + + ` EXPOSE 8080 8000 9000\n` + + ` CMD ["opentelemetry-instrument", "python", "-m", "main"]\n\n` + + `Also ensure the build sets the runtime entrypoint to the generated main.py rather than ` + + `the harness's overridden entrypoint.`, + }; +} + +/** Note emitted when a harness references a dockerfile that does not exist on disk. */ +export function buildMissingDockerfileNote( + dockerfile: string, + harnessName: string, + targetAgentName: string +): ExportNote { + return { + category: `Dockerfile not found — create ${dockerfile} before deploying`, + message: + `The harness references dockerfile: "${dockerfile}" but no such file exists in ` + + `app/${harnessName}/. Create a Dockerfile at app/${targetAgentName}/${dockerfile} ` + + `before running \`agentcore deploy\`.`, + }; +} + +// ============================================================================ +// Write EXPORT_NOTES.md +// ============================================================================ + +function readStrandsVersion(agentDir: string): string { + try { + const pyproject = readFileSync(join(agentDir, 'pyproject.toml'), 'utf8'); + const match = /strands-agents\s*(>=\s*[\d.]+)/.exec(pyproject); + return match ? `strands-agents ${match[1]}` : 'strands-agents (version unknown)'; + } catch { + return 'strands-agents (version unknown)'; + } +} + +function writeExportNotes(notes: ExportNote[], harnessName: string, agentName: string, agentDir: string): void { + const today = new Date().toISOString().split('T')[0]; + const strandsVersion = readStrandsVersion(agentDir); + const lines: string[] = [ + `# Export Notes — ${harnessName} → ${agentName}`, + '', + `Exported on: ${today}`, + `Strands version: ${strandsVersion}`, + `Source harness: agentcore/app/${harnessName}/harness.json`, + `Generated agent: app/${agentName}/`, + '', + ]; + + if (notes.length === 0) { + lines.push('No manual steps required.'); + } else { + lines.push('## Items requiring manual follow-up'); + for (const note of notes) { + lines.push(''); + lines.push(`### ${note.category}`); + lines.push(note.message); + } + } + + lines.push(''); + + const outPath = join(agentDir, EXPORT_NOTES_FILENAME); + writeFileSync(outPath, lines.join('\n'), 'utf8'); +} + +// ============================================================================ +// Dockerfile stub for containerUri harnesses +// ============================================================================ + +function writeDockerfileStub(agentDir: string, containerUri: string): void { + const content = [ + `# Base image from the source harness: ${containerUri}`, + '# The generated Strands agent is layered on top. If the base image does not', + '# include Python 3.12+ or uv, add install steps before the COPY/RUN below.', + `FROM ${containerUri}`, + '', + 'RUN pip install --no-cache-dir uv', + '', + 'WORKDIR /app', + '', + 'ARG UV_DEFAULT_INDEX', + 'ARG UV_INDEX', + '', + 'ENV UV_SYSTEM_PYTHON=1 \\', + ' UV_COMPILE_BYTECODE=1 \\', + ' UV_NO_PROGRESS=1 \\', + ' PYTHONUNBUFFERED=1 \\', + ' DOCKER_CONTAINER=1 \\', + ' UV_DEFAULT_INDEX=${UV_DEFAULT_INDEX} \\', + ' UV_INDEX=${UV_INDEX} \\', + ' PATH="/app/.venv/bin:$PATH"', + '', + 'RUN useradd -m -u 1000 bedrock_agentcore', + '', + 'COPY pyproject.toml uv.lock ./', + 'RUN uv sync --frozen --no-dev --no-install-project', + '', + 'COPY --chown=bedrock_agentcore:bedrock_agentcore . .', + 'RUN uv sync --frozen --no-dev', + '', + 'USER bedrock_agentcore', + '', + 'EXPOSE 8080 8000 9000', + '', + 'CMD ["opentelemetry-instrument", "python", "-m", "main"]', + '', + ].join('\n'); + + writeFileSync(join(agentDir, 'Dockerfile'), content, 'utf8'); +} diff --git a/src/cli/commands/export/harness-mapper.ts b/src/cli/commands/export/harness-mapper.ts new file mode 100644 index 000000000..988e3aa72 --- /dev/null +++ b/src/cli/commands/export/harness-mapper.ts @@ -0,0 +1,956 @@ +import { APP_DIR } from '../../../lib'; +import { ValidationError } from '../../../lib/errors/types'; +import type { + AgentEnvSpec, + BuildType, + Credential, + DirectoryPath, + FilePath, + MemoryStrategy, + MemoryStrategyType, + ModelProvider, +} from '../../../schema'; +import type { + HarnessGatewayOutboundAuth, + HarnessSkill, + HarnessSkillAwsSkillsSource, + HarnessSkillGitSource, + HarnessSkillPathSource, + HarnessSkillS3Source, + HarnessSpec, + HarnessToolType, + HarnessTruncationConfig, +} from '../../../schema/schemas/primitives/harness'; +import { arnPrefix, dnsSuffix } from '../../aws/partition'; +import { GatewayPrimitive } from '../../primitives/GatewayPrimitive'; +import { + computeDefaultCredentialEnvVarName, + computeManagedOAuthCredentialName, +} from '../../primitives/credential-utils'; +import type { + AgentRenderConfig, + GatewayProviderRenderConfig, + IdentityProviderRenderConfig, + MemoryProviderRenderConfig, +} from '../../templates/types'; +import { DEFAULT_PYTHON_ENTRYPOINT, DEFAULT_PYTHON_VERSION } from '../../tui/screens/generate/defaults'; +import { buildFilesystemConfigurations } from '../shared/filesystem-utils'; +import { + ALLOWED_TOOLS_NOTE_CATEGORY, + AWS_SKILLS_NOTE_CATEGORY, + BROWSER_CODZIP_NOTE_CATEGORY, + BROWSER_IAM_POLICY_NOTE_CATEGORY, + CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY, + CONTAINER_URI_ECR_PULL_NOTE_CATEGORY, + CONTAINER_URI_NOTE_CATEGORY, + EXTERNAL_GATEWAY_NOTE_CATEGORY, + GATEWAY_IAM_POLICY_NOTE_CATEGORY, + GIT_SKILLS_CONTAINER_NOTE_CATEGORY, + MCP_HEADER_CREDS_NOTE_CATEGORY, + MEMORY_ARN_NOTE_CATEGORY, + PATH_SKILLS_NOTE_CATEGORY, + S3_SKILLS_IAM_POLICY_NOTE_CATEGORY, +} from './constants'; +import type { HarnessMappingResult, ResolvedHarnessContext } from './types'; + +// ============================================================================ +// Public entry point +// ============================================================================ + +export function mapHarnessToExportConfig( + context: ResolvedHarnessContext, + buildOverride?: BuildType +): HarnessMappingResult { + const { spec, targetAgentName } = context; + + const buildType = resolveBuildType(spec, buildOverride); + + if (buildType === 'CodeZip' && (spec.containerUri || spec.dockerfile)) { + const what = spec.containerUri ? `containerUri (${spec.containerUri})` : `dockerfile (${spec.dockerfile})`; + throw new ValidationError( + `Harness "${spec.name}" uses ${what}, which requires a Container build. ` + `Re-export with --build Container.` + ); + } + + const modelProvider = resolveModelProvider(spec.model.provider); + const allowedToolPatterns = spec.allowedTools ?? ['*']; + const identityResult = resolveIdentityProvider(spec, context); + const memoryResult = resolveMemoryProviders(spec, context); + const gatewayResult = resolveGatewayProviders(spec, context, allowedToolPatterns); + const hasGateway = gatewayResult.providers.length > 0; + addBrowserCodeInterpreterNotes(spec, allowedToolPatterns, buildType, context); + const hasExecutionLimits = + spec.maxIterations !== undefined || spec.maxTokens !== undefined || spec.timeoutSeconds !== undefined; + const hasSkillsFetcher = spec.skills.length > 0; + + // Static allowedTools filter — record note if not wildcard + if (!(allowedToolPatterns.length === 1 && allowedToolPatterns[0] === '*')) { + context.exportNotes.push({ + category: ALLOWED_TOOLS_NOTE_CATEGORY, + message: + 'The harness allowedTools filter has been applied statically at code-generation time. ' + + 'Tools excluded at export will not be available at runtime, and callers cannot override ' + + 'the tool list per invocation (unlike the harness).', + }); + } + + // Path skills note + const pathSkills = spec.skills.filter(s => isPathSkill(s)); + if (pathSkills.length > 0 && buildType === 'CodeZip') { + context.exportNotes.push({ + category: PATH_SKILLS_NOTE_CATEGORY, + message: + `The following skill paths must exist on the container filesystem at runtime: ${pathSkills.map(s => s.path).join(', ')}. ` + + 'For CodeZip builds, path skills are not supported — switch to a Container build and COPY the ' + + 'skill directory in your Dockerfile, or use s3/git skill variants.', + }); + } + + // git skills + Container: warn that git must be in the image + const gitSkills = spec.skills.filter(s => isGitSkill(s)); + if (gitSkills.length > 0 && buildType === 'Container') { + context.exportNotes.push({ + category: GIT_SKILLS_CONTAINER_NOTE_CATEGORY, + message: + 'The agent clones git skill repositories at runtime using `git`. The default Container base image ' + + '(`python:3.12-slim`) does not include git. Add it to your Dockerfile before deploying:\n\n' + + ' RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*', + }); + } + + // s3 skills: the agent fetches skill files with boto3 at runtime, so the runtime + // execution role needs S3 read access. The managed harness fetched these service-side + // and never granted the customer role S3 permissions, so without this the exported + // agent fails at first invocation with an opaque S3 AccessDenied. Independent of build type. + const s3Skills = spec.skills.filter(isS3Skill); + if (s3Skills.length > 0) { + const arns = s3Skills + .map(s => parseS3SkillArns(s.s3Uri, context.region)) + .filter((a): a is NonNullable => a !== undefined); + if (arns.length > 0) { + const objectResources = [...new Set(arns.map(a => a.objectArn))]; + const bucketResources = [...new Set(arns.map(a => a.bucketArn))]; + const fmt = (rs: string[]) => rs.map(r => `'${r}'`).join(', '); + const agentName = context.targetAgentName ?? 'YourAgentName'; + context.exportNotes.push({ + category: S3_SKILLS_IAM_POLICY_NOTE_CATEGORY, + message: + `This agent downloads its S3 skills (${s3Skills.map(s => s.s3Uri).join(', ')}) at runtime with boto3. ` + + `The exported runtime execution role is not automatically granted permission to read them, so the ` + + `agent will fail on its first invocation with an S3 AccessDenied.\n\n` + + `Add the following to agentcore/cdk/lib/cdk-stack.ts after \`this.application\` is created,\n` + + `replacing "${agentName}" if you renamed the agent:\n\n` + + ` const agentEnv = this.application.environments.get('${agentName}');\n` + + ` agentEnv?.runtime.role.addToPrincipalPolicy(\n` + + ` new iam.PolicyStatement({\n` + + ` actions: ['s3:GetObject'],\n` + + ` resources: [${fmt(objectResources)}],\n` + + ` })\n` + + ` );\n` + + ` agentEnv?.runtime.role.addToPrincipalPolicy(\n` + + ` new iam.PolicyStatement({\n` + + ` actions: ['s3:ListBucket'],\n` + + ` resources: [${fmt(bucketResources)}],\n` + + ` })\n` + + ` );`, + }); + } + } + + // AWS skills: managed-only feature, cannot export + const awsSkills = spec.skills.filter(s => isAwsSkill(s)); + if (awsSkills.length > 0) { + const patterns = awsSkills.map(s => s.awsSkills.paths?.join(', ') ?? 'all').join('; '); + context.exportNotes.push({ + category: AWS_SKILLS_NOTE_CATEGORY, + message: + `AWS skills are a managed harness feature and are not available in standalone Strands agents. ` + + `The following skill patterns have been omitted: ${patterns}. ` + + `You can copy the equivalent skills from https://github.com/aws/agent-toolkit-for-aws/tree/main/skills ` + + `into your project and load them as path or git skills instead.`, + }); + } + + // containerUri note + if (spec.containerUri) { + context.exportNotes.push({ + category: CONTAINER_URI_NOTE_CATEGORY, + message: + `The harness used a pre-built container image as its execution environment (${spec.containerUri}). ` + + 'The generated Dockerfile extends that image directly (FROM ) and layers the Strands ' + + 'agent code on top. If your base image does not include Python 3.12+ or uv, add an install step ' + + 'before the `uv sync` steps.', + }); + + // If the base image is a private ECR repository, CodeBuild needs pull access to it. + const baseImageEcrArn = ecrArnFromUri(spec.containerUri, context.region); + if (baseImageEcrArn) { + context.exportNotes.push({ + category: CONTAINER_URI_ECR_PULL_NOTE_CATEGORY, + message: + `The base image (${spec.containerUri}) is a private ECR repository. The CodeBuild project that ` + + `builds this agent's container is not automatically granted permission to pull it.\n\n` + + `Add the following to agentcore/cdk/lib/cdk-stack.ts after \`this.application\` is created:\n\n` + + ` import * as ecr from 'aws-cdk-lib/aws-ecr';\n` + + ` import { ContainerBuildProject } from '@aws/agentcore-cdk';\n\n` + + ` const baseRepo = ecr.Repository.fromRepositoryArn(this, 'BaseImageEcrRepository', '${baseImageEcrArn}');\n` + + ` baseRepo.grantPull(ContainerBuildProject.getOrCreate(this).role);`, + }); + } + } + + const mcpResolution = resolveRemoteMcpTools(spec, allowedToolPatterns, context); + + const renderConfig: AgentRenderConfig = { + name: targetAgentName, + sdkFramework: 'Strands', + targetLanguage: 'Python', + modelProvider, + hasMemory: memoryResult.providers.length > 0, + hasIdentity: identityResult.provider !== null, + hasGateway, + isVpc: spec.networkMode === 'VPC', + buildType, + memoryProviders: memoryResult.providers, + identityProviders: identityResult.provider ? [identityResult.provider] : [], + gatewayProviders: gatewayResult.providers, + gatewayAuthTypes: [...new Set(gatewayResult.providers.map(g => g.authType))], + protocol: 'HTTP', + dockerfile: resolveDockerfileName(spec, buildType), + enableOtel: true, + hasConfigBundle: false, + hasPayment: false, + // Execution limits — consumed by execution-limits capability template + maxIterations: spec.maxIterations, + maxTokens: spec.maxTokens, + timeoutSeconds: spec.timeoutSeconds, + // Truncation — consumed by main.py template + truncationStrategy: spec.truncation?.strategy, + truncationConfig: resolveTruncationConfig(spec.truncation), + // Remote MCP tools — consumed by mcp_client template + remoteMcpTools: mcpResolution.tools, + // Filesystem mounts (session storage, EFS, S3) — consumed by main.py/CDK templates + ...buildFilesystemRenderConfig(spec), + // Skills (path/s3/git) — consumed by main.py + skills/fetcher.py templates + ...buildSkillsRenderConfig(spec, hasSkillsFetcher), + // Inline + builtin + browser/code-interpreter tools (after allowedTools filter) + ...buildToolsRenderConfig(spec, allowedToolPatterns, buildType), + hasExecutionLimits, + isExportHarness: true, + modelId: spec.model.modelId, + // System prompt (written verbatim into main.py) + systemPromptText: context.systemPrompt, + actorId: spec.memory?.mode === 'existing' ? spec.memory.actorId : undefined, + }; + + const agentEnvSpec = buildAgentEnvSpec(context, targetAgentName, buildType); + + return { + renderConfig, + agentEnvSpec, + credentialEntry: identityResult.credentialEntry, + mcpCredentialEntries: mcpResolution.credentialEntries, + }; +} + +// ============================================================================ +// RenderConfig sub-builders +// ============================================================================ + +/** Filesystem mounts (session storage, EFS, S3) consumed by main.py and CDK templates. */ +function buildFilesystemRenderConfig( + spec: HarnessSpec +): Pick { + const efsMounts = (spec.efsAccessPoints ?? []).map(ap => ({ + accessPointArn: ap.accessPointArn, + mountPath: ap.mountPath, + })); + const s3Mounts = (spec.s3AccessPoints ?? []).map(ap => ({ + accessPointArn: ap.accessPointArn, + mountPath: ap.mountPath, + })); + return { + sessionStorageMountPath: spec.sessionStoragePath, + efsMounts, + s3Mounts, + needsOs: !!spec.sessionStoragePath || efsMounts.length > 0 || s3Mounts.length > 0, + }; +} + +/** Path/S3/git skills consumed by main.py and skills/fetcher.py templates. */ +function buildSkillsRenderConfig( + spec: HarnessSpec, + hasSkillsFetcher: boolean +): Pick { + return { + pathSkills: spec.skills.filter(isPathSkill).map(s => s.path), + s3Skills: spec.skills.filter(isS3Skill).map(s => s.s3Uri), + gitSkills: spec.skills.filter(isGitSkill).map(s => ({ + url: s.gitUrl, + path: s.path, + credentialArn: s.auth?.credentialName, + username: s.auth?.username, + })), + hasSkillsFetcher, + hasFetchedSkills: spec.skills.some(s => isS3Skill(s) || isGitSkill(s)), + }; +} + +/** Inline, builtin, and browser/code-interpreter tools (after allowedTools filter). */ +function buildToolsRenderConfig( + spec: HarnessSpec, + allowedToolPatterns: string[], + buildType: BuildType +): Pick< + AgentRenderConfig, + | 'inlineFunctionTools' + | 'hasBrowser' + | 'browserIdentifier' + | 'hasCodeInterpreter' + | 'codeInterpreterIdentifier' + | 'hasShell' + | 'hasFileOperations' +> { + return { + inlineFunctionTools: resolveInlineFunctionTools(spec, allowedToolPatterns), + // Browser requires a Container build (Playwright driver can't spawn subprocesses in CodeZip Lambda sandbox). + hasBrowser: isToolIncluded('agentcore_browser', spec, allowedToolPatterns) && buildType === 'Container', + browserIdentifier: extractToolIdentifier(spec, 'agentcore_browser', 'agentCoreBrowser', 'browserArn'), + hasCodeInterpreter: isToolIncluded('agentcore_code_interpreter', spec, allowedToolPatterns), + codeInterpreterIdentifier: extractToolIdentifier( + spec, + 'agentcore_code_interpreter', + 'agentCoreCodeInterpreter', + 'codeInterpreterArn' + ), + // Builtin tools — always available in the Harness runtime, included unless filtered out by allowedTools + hasShell: isBuiltinIncluded('shell', allowedToolPatterns), + hasFileOperations: isBuiltinIncluded('file_operations', allowedToolPatterns), + }; +} + +// ============================================================================ +// AgentEnvSpec builder +// ============================================================================ + +function buildAgentEnvSpec( + context: ResolvedHarnessContext, + targetAgentName: string, + buildType: BuildType +): AgentEnvSpec { + const { spec } = context; + const codeLocation = `${APP_DIR}/${targetAgentName}/` as DirectoryPath; + + const envVars = Object.entries(spec.environmentVariables ?? {}).map(([name, value]) => ({ name, value })); + + return { + name: targetAgentName, + build: buildType, + ...(resolveDockerfileName(spec, buildType) && { dockerfile: resolveDockerfileName(spec, buildType)! as FilePath }), + entrypoint: DEFAULT_PYTHON_ENTRYPOINT as FilePath, + codeLocation, + runtimeVersion: DEFAULT_PYTHON_VERSION, + networkMode: spec.networkMode ?? 'PUBLIC', + protocol: 'HTTP', + ...(spec.networkMode === 'VPC' && spec.networkConfig && { networkConfig: spec.networkConfig }), + ...(spec.authorizerType && { authorizerType: spec.authorizerType }), + ...(spec.authorizerConfiguration && { authorizerConfiguration: spec.authorizerConfiguration }), + ...(spec.lifecycleConfig && { + lifecycleConfiguration: { + ...(spec.lifecycleConfig.idleRuntimeSessionTimeout !== undefined && { + idleRuntimeSessionTimeout: spec.lifecycleConfig.idleRuntimeSessionTimeout, + }), + ...(spec.lifecycleConfig.maxLifetime !== undefined && { + maxLifetime: spec.lifecycleConfig.maxLifetime, + }), + }, + }), + ...(spec.executionRoleArn && { executionRoleArn: spec.executionRoleArn }), + ...(envVars.length > 0 && { envVars }), + ...(spec.tags && { tags: spec.tags }), + ...buildFilesystemConfigurations(spec.sessionStoragePath, spec.efsAccessPoints, spec.s3AccessPoints), + }; +} + +// ============================================================================ +// Model provider +// ============================================================================ + +function resolveModelProvider(provider: 'bedrock' | 'open_ai' | 'gemini' | 'lite_llm'): ModelProvider { + switch (provider) { + case 'bedrock': + return 'Bedrock'; + case 'open_ai': + return 'OpenAI'; + case 'gemini': + return 'Gemini'; + case 'lite_llm': + throw new ValidationError( + 'Harness uses the "lite_llm" model provider, which the Strands export does not support. ' + + 'Switch the harness to bedrock, open_ai, or gemini before exporting.' + ); + } +} + +// ============================================================================ +// Identity provider (non-Bedrock model credential) +// ============================================================================ + +interface IdentityResult { + provider: IdentityProviderRenderConfig | null; + credentialEntry: Credential | null; +} + +function resolveIdentityProvider(spec: HarnessSpec, context: ResolvedHarnessContext): IdentityResult { + if (spec.model.provider === 'bedrock') { + return { provider: null, credentialEntry: null }; + } + + const apiKeyArn = spec.model.apiKeyArn; + if (!apiKeyArn) { + return { provider: null, credentialEntry: null }; + } + + // Try to find an existing credential in the project that matches the ARN + const existing = context.projectSpec.credentials.find(c => { + if ('apiKeyArn' in c) return (c as { apiKeyArn?: string }).apiKeyArn === apiKeyArn; + if ('credentialProviderArn' in c) + return (c as { credentialProviderArn?: string }).credentialProviderArn === apiKeyArn; + return false; + }); + + if (existing) { + return { + provider: { name: existing.name, envVarName: computeDefaultCredentialEnvVarName(existing.name) }, + credentialEntry: null, // already in project + }; + } + + // Extract the credential provider name from a token-vault ARN if possible, otherwise + // synthesize one from the project name + provider. This ensures the deployed credential + // entry references the same provider that was used in the harness. + // ARN format: arn:aws:bedrock-agentcore:::token-vault//apikeycredentialprovider/ + const arnNameMatch = /\/apikeycredentialprovider\/([^/]+)$/.exec(apiKeyArn); + const credentialName = arnNameMatch + ? arnNameMatch[1]! + : `${context.projectSpec.name}${resolveModelProvider(spec.model.provider)}`; + const credentialEntry: Credential = { + authorizerType: 'ApiKeyCredentialProvider', + name: credentialName, + }; + + return { + provider: { name: credentialName, envVarName: computeDefaultCredentialEnvVarName(credentialName) }, + credentialEntry, + }; +} + +// ============================================================================ +// Memory providers +// ============================================================================ + +interface MemoryResult { + providers: MemoryProviderRenderConfig[]; +} + +function resolveMemoryProviders(spec: HarnessSpec, context: ResolvedHarnessContext): MemoryResult { + // Only an `existing` reference resolves a concrete memory to wire as an env var. Managed memory's + // ARN is service-populated at deploy time (nothing to resolve at export); disabled has none. + if (spec.memory?.mode !== 'existing') return { providers: [] }; + + const { name: memName, arn: memArn } = spec.memory; + + if (memName) { + // Same-project memory by name + const memEntry = context.projectSpec.memories?.find(m => m.name === memName); + const strategies: MemoryStrategyType[] = (memEntry?.strategies ?? []).map((s: MemoryStrategy) => s.type); + const envVarName = `MEMORY_${memName.toUpperCase()}_ID`; + return { + providers: [{ name: memName, envVarName, strategies }], + }; + } + + if (memArn) { + // Try to cross-reference against deployed state + const deployedMemories = context.deployedResources?.memories ?? {}; + const match = Object.entries(deployedMemories).find(([, state]) => state.memoryArn === memArn); + if (match) { + const [resolvedName] = match; + const memEntry = context.projectSpec.memories?.find(m => m.name === resolvedName); + const strategies: MemoryStrategyType[] = (memEntry?.strategies ?? []).map((s: MemoryStrategy) => s.type); + const envVarName = `MEMORY_${resolvedName.toUpperCase()}_ID`; + return { + providers: [{ name: resolvedName, envVarName, strategies }], + }; + } + + // External memory — hardcode ARN as env var + context.exportNotes.push({ + category: MEMORY_ARN_NOTE_CATEGORY, + message: + `The harness memory was referenced by ARN (${memArn}) and could not be matched to a ` + + 'same-project memory. A MEMORY_ARN env var will be used. Ensure the runtime IAM execution role ' + + 'has bedrock-agentcore:GetMemory and bedrock-agentcore:InvokeMemory on the above ARN.', + }); + return { + providers: [{ name: 'ExternalMemory', envVarName: 'MEMORY_ARN', strategies: [] }], + }; + } + + return { providers: [] }; +} + +// ============================================================================ +// Gateway providers +// ============================================================================ + +interface GatewayResult { + providers: GatewayProviderRenderConfig[]; +} + +function resolveGatewayProviders( + spec: HarnessSpec, + context: ResolvedHarnessContext, + allowedToolPatterns: string[] +): GatewayResult { + const providers: GatewayProviderRenderConfig[] = []; + + for (const tool of spec.tools) { + if (tool.type !== 'agentcore_gateway') continue; + if (!tool.config || !('agentCoreGateway' in tool.config)) continue; + if (!matchesAllowedTools(tool.name, allowedToolPatterns)) continue; + + const gwConfig = ( + tool.config as { agentCoreGateway: { gatewayArn: string; outboundAuth?: HarnessGatewayOutboundAuth } } + ).agentCoreGateway; + const gatewayArn = gwConfig.gatewayArn; + + // Try to find in deployed state (same-project gateway) + const deployedGateways = context.deployedResources?.mcp?.gateways ?? {}; + const deployedMatch = Object.entries(deployedGateways).find(([, state]) => state.gatewayArn === gatewayArn); + + if (deployedMatch) { + const [gatewayName] = deployedMatch; + const projectGateway = context.projectSpec.agentCoreGateways?.find(g => g.name === gatewayName); + const authType = projectGateway?.authorizerType ?? 'AWS_IAM'; + + const provider: GatewayProviderRenderConfig = { + name: gatewayName, + envVarName: GatewayPrimitive.computeDefaultGatewayEnvVarName(gatewayName), + authType, + }; + + if (authType === 'CUSTOM_JWT' && projectGateway?.authorizerConfiguration?.customJwtAuthorizer) { + const jwtConfig = projectGateway.authorizerConfiguration.customJwtAuthorizer; + provider.discoveryUrl = jwtConfig.discoveryUrl; + provider.credentialProviderName = computeManagedOAuthCredentialName(gatewayName); + const scopes = + 'allowedScopes' in jwtConfig ? (jwtConfig as { allowedScopes?: string[] }).allowedScopes : undefined; + if (scopes?.length) { + provider.scopes = scopes.join(' '); + } + } + + providers.push(provider); + // Same-project gateway: AgentCoreMcp.wireGatewayUrlsToAgents() auto-grants InvokeGateway + // to all runtime environments — no manual IAM step needed. + } else { + // External gateway — derive URL from ARN + const hardcodedUrl = deriveGatewayUrl(gatewayArn); + context.exportNotes.push({ + category: EXTERNAL_GATEWAY_NOTE_CATEGORY, + message: + `Gateway tool "${tool.name}" (ARN: ${gatewayArn}) was not found in this project's deployed state. ` + + `The URL has been hardcoded as "${hardcodedUrl}" in mcp_client/client.py. ` + + 'If the ARN changes (e.g. after re-deployment), update mcp_client/client.py manually.', + }); + + const outboundAuth = gwConfig.outboundAuth; + const authType = outboundAuth + ? 'oauth' in outboundAuth + ? 'CUSTOM_JWT' + : 'awsIam' in outboundAuth + ? 'AWS_IAM' + : 'NONE' + : 'AWS_IAM'; + + if (authType === 'AWS_IAM') { + context.exportNotes.push({ + category: GATEWAY_IAM_POLICY_NOTE_CATEGORY, + message: + `Gateway tool "${tool.name}" (ARN: ${gatewayArn}) uses AWS_IAM auth. ` + + `The exported runtime execution role is not automatically granted permission to invoke it.\n\n` + + `Add the following to agentcore/cdk/lib/cdk-stack.ts after \`this.application\` is created,\n` + + `replacing "YourAgentName" with the name of the exported agent (e.g. "${context.targetAgentName ?? 'MyHarnessAgent'}"):\n\n` + + ` const agentEnv = this.application.environments.get('${context.targetAgentName ?? 'YourAgentName'}');\n` + + ` agentEnv?.runtime.role.addToPrincipalPolicy(\n` + + ` new iam.PolicyStatement({\n` + + ` actions: ['bedrock-agentcore:InvokeGateway'],\n` + + ` resources: ['${gatewayArn}'],\n` + + ` })\n` + + ` );`, + }); + } + + const provider: GatewayProviderRenderConfig = { + name: tool.name, + envVarName: '', + authType, + hardcodedUrl, + }; + + if (authType === 'CUSTOM_JWT' && outboundAuth && 'oauth' in outboundAuth) { + provider.credentialProviderName = computeManagedOAuthCredentialName(tool.name); + const scopes = outboundAuth.oauth.scopes; + if (scopes?.length) { + provider.scopes = scopes.join(' '); + } + } + + providers.push(provider); + } + } + + return { providers }; +} + +// ============================================================================ +// Inline function tools +// ============================================================================ + +interface InlineFunctionTool { + name: string; + description: string; + inputSchema: Record; +} + +function resolveInlineFunctionTools(spec: HarnessSpec, allowedPatterns: string[]): InlineFunctionTool[] { + return spec.tools + .filter(t => t.type === 'inline_function') + .filter(t => matchesAllowedTools(t.name, allowedPatterns)) + .map(t => { + const cfg = (t.config as { inlineFunction: { description: string; inputSchema: Record } }) + .inlineFunction; + return { name: t.name, description: cfg.description, inputSchema: cfg.inputSchema }; + }); +} + +// ============================================================================ +// Remote MCP tools +// ============================================================================ + +interface RemoteMcpTool { + name: string; + url: string; + headerCredentials?: { headerKey: string; credentialName: string; envVarName: string }[]; +} + +interface McpCredentialEntry { + credential: Credential; + envVarName: string; + value: string; +} + +interface RemoteMcpResolution { + tools: RemoteMcpTool[]; + credentialEntries: McpCredentialEntry[]; +} + +function resolveRemoteMcpTools( + spec: HarnessSpec, + allowedPatterns: string[], + context: ResolvedHarnessContext +): RemoteMcpResolution { + const tools: RemoteMcpTool[] = []; + const credentialEntries: McpCredentialEntry[] = []; + + for (const tool of spec.tools) { + if (tool.type !== 'remote_mcp') continue; + if (!matchesAllowedTools(tool.name, allowedPatterns)) continue; + if (!tool.config || !('remoteMcp' in tool.config)) continue; + + const cfg = (tool.config as { remoteMcp: { url: string; headers?: Record } }).remoteMcp; + const headerKeys = Object.keys(cfg.headers ?? {}); + + let headerCredentials: RemoteMcpTool['headerCredentials']; + if (headerKeys.length > 0) { + headerCredentials = []; + const toolPrefix = tool.name.replace(/[^A-Za-z0-9]/g, ''); + for (const hdr of headerKeys) { + const credName = `${context.projectSpec.name}Mcp${toolPrefix}${hdr.replace(/[^A-Za-z0-9]/g, '')}`; + const envVarName = computeDefaultCredentialEnvVarName(credName); + headerCredentials.push({ headerKey: hdr, credentialName: credName, envVarName }); + credentialEntries.push({ + credential: { authorizerType: 'ApiKeyCredentialProvider', name: credName }, + envVarName, + value: cfg.headers![hdr] ?? '', + }); + } + context.exportNotes.push({ + category: MCP_HEADER_CREDS_NOTE_CATEGORY, + message: + `MCP tool "${tool.name}" has request headers managed via AgentCore Identity. ` + + `Credential entries added to agentcore.json; values written to agentcore/.env.local. ` + + `Credentials are provisioned automatically on \`agentcore deploy\`.\n\n` + + headerCredentials.map(h => ` ${h.credentialName} (env var: ${h.envVarName})`).join('\n'), + }); + } + + tools.push({ name: tool.name, url: cfg.url, headerCredentials }); + } + + return { tools, credentialEntries }; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function resolveBuildType(spec: HarnessSpec, override?: BuildType): BuildType { + if (override) return override; + if (spec.containerUri || spec.dockerfile) return 'Container'; + return 'CodeZip'; +} + +function resolveDockerfileName(spec: HarnessSpec, buildType: BuildType): string | undefined { + if (buildType !== 'Container') return undefined; + if (spec.dockerfile) return spec.dockerfile; + if (spec.containerUri) return 'Dockerfile'; // we generate it + return undefined; +} + +function deriveGatewayUrl(gatewayArn: string): string { + // arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/abc123 + const parts = gatewayArn.split(':'); + const region = parts[3] ?? 'us-east-1'; + const resourcePart = parts[parts.length - 1] ?? ''; + const gatewayId = resourcePart.replace('gateway/', ''); + return `https://${gatewayId}.gateway.bedrock-agentcore.${region}.${dnsSuffix(region)}/mcp`; +} + +/** + * Extract the ECR repository ARN from an ECR image URI. + * Returns undefined if the URI is not an ECR private registry URI. + * + * Handles formats: + * .dkr.ecr..amazonaws.com/: + * .dkr.ecr..amazonaws.com/@sha256: + */ +function ecrArnFromUri(uri: string, region?: string): string | undefined { + // Match private ECR URIs: .dkr.ecr../[:|@] + const match = /^(\d{12})\.dkr\.ecr\.([^.]+)\.[^/]+\/([^:@]+)/.exec(uri); + if (!match) return undefined; + const account = match[1]; + const ecrRegion = match[2] ?? region; + const repoName = match[3]; + if (!ecrRegion || !repoName) return undefined; + return `${arnPrefix(ecrRegion)}:ecr:${ecrRegion}:${account}:repository/${repoName}`; +} + +function isPathSkill(skill: HarnessSkill): skill is HarnessSkillPathSource { + return 'path' in skill && !('gitUrl' in skill); +} + +function isS3Skill(skill: HarnessSkill): skill is HarnessSkillS3Source { + return 's3Uri' in skill; +} + +/** + * Parse an s3:// skill URI into the bucket name and its object ARN. + * The exported agent fetches skills with boto3 at runtime (skills/fetcher.py), + * so the runtime execution role needs s3:ListBucket on the bucket and + * s3:GetObject on the objects under the prefix — permissions the managed + * harness never needed (it fetches skill files service-side). + * + * Returns undefined for a malformed URI (no bucket). + */ +function parseS3SkillArns( + s3Uri: string, + region?: string +): { bucket: string; bucketArn: string; objectArn: string } | undefined { + const withoutScheme = s3Uri.replace(/^s3:\/\//, ''); + const [bucket, ...prefixParts] = withoutScheme.split('/'); + if (!bucket) return undefined; + // S3 ARNs are partition-qualified but region/account-less: arn::s3::: + const partition = arnPrefix(region ?? 'us-east-1'); // arnPrefix -> "arn:aws" | "arn:aws-us-gov" | "arn:aws-cn" + const bucketArn = `${partition}:s3:::${bucket}`; + const prefix = prefixParts.join('/').replace(/\/+$/, ''); + const objectArn = prefix ? `${bucketArn}/${prefix}/*` : `${bucketArn}/*`; + return { bucket, bucketArn, objectArn }; +} + +function isGitSkill(skill: HarnessSkill): skill is HarnessSkillGitSource { + return 'gitUrl' in skill; +} + +function isAwsSkill(skill: HarnessSkill): skill is HarnessSkillAwsSkillsSource { + return 'awsSkills' in skill; +} + +function isToolIncluded(toolType: HarnessToolType, spec: HarnessSpec, allowedPatterns: string[]): boolean { + const tool = spec.tools.find(t => t.type === toolType); + if (!tool) return false; + return matchesAllowedTools(tool.name, allowedPatterns); +} + +function matchesAllowedTools(toolName: string, patterns: string[]): boolean { + if (patterns.includes('*')) return true; + // Mirrors Harness runtime _matches() logic: tool_name is "server/tool" qualified + for (const pattern of patterns) { + if (pattern === toolName) return true; + if (pattern.startsWith('@')) { + const slashIdx = pattern.indexOf('/', 1); + const pServer = slashIdx === -1 ? pattern.slice(1) : pattern.slice(1, slashIdx); + const pTool = slashIdx === -1 ? '*' : pattern.slice(slashIdx + 1); + const slashInName = toolName.indexOf('/'); + if (slashInName === -1) { + // MCP tools stored as "server_tool" flat names — keep legacy behaviour + if (fnmatch(`${pServer}_${pTool}`, toolName)) return true; + } else { + // Qualified names like "builtin/shell" + const nameServer = toolName.slice(0, slashInName); + const nameTool = toolName.slice(slashInName + 1); + if (fnmatch(pServer, nameServer) && fnmatch(pTool, nameTool)) return true; + } + } else { + if (fnmatch(pattern, toolName)) return true; + } + } + return false; +} + +function resolveTruncationConfig(truncation: HarnessTruncationConfig | undefined): Record | undefined { + if (!truncation?.config) return undefined; + const { strategy, config } = truncation; + if (strategy === 'sliding_window' && 'slidingWindow' in config) { + const sw = config.slidingWindow; + return sw?.messagesCount !== undefined ? { window_size: sw.messagesCount } : undefined; + } + if (strategy === 'summarization' && 'summarization' in config) { + const s = config.summarization as Record; + const keyMap: Record = { + summaryRatio: 'summary_ratio', + preserveRecentMessages: 'preserve_recent_messages', + summarizationSystemPrompt: 'summarization_system_prompt', + }; + const out = Object.fromEntries( + Object.entries(keyMap) + .filter(([k]) => s[k] !== undefined) + .map(([k, v]) => [v, s[k]]) + ); + return Object.keys(out).length > 0 ? out : undefined; + } + return undefined; +} + +function extractToolIdentifier( + spec: HarnessSpec, + toolType: HarnessToolType, + configKey: string, + arnField: string +): string | undefined { + const tool = spec.tools.find(t => t.type === toolType); + if (!tool?.config || !(configKey in tool.config)) return undefined; + const arn = (tool.config as Record>)[configKey]?.[arnField]; + if (!arn) return undefined; + // ARN format: arn:aws:bedrock-agentcore:::/ + const slashIdx = arn.lastIndexOf('/'); + return slashIdx === -1 ? undefined : arn.slice(slashIdx + 1); +} + +function isBuiltinIncluded(builtinName: string, patterns: string[]): boolean { + // Mirrors Harness runtime: builtins are keyed as "builtin/", so only @builtin or @builtin/ patterns match. + // Plain "shell" does NOT match the "builtin/shell" builtin (it would match a tool literally named "shell"). + return matchesAllowedTools(`builtin/${builtinName}`, patterns); +} + +function addBrowserCodeInterpreterNotes( + spec: HarnessSpec, + allowedToolPatterns: string[], + buildType: BuildType, + context: ResolvedHarnessContext +): void { + const agentName = context.targetAgentName; + + if (isToolIncluded('agentcore_browser', spec, allowedToolPatterns)) { + if (buildType !== 'Container') { + context.exportNotes.push({ + category: BROWSER_CODZIP_NOTE_CATEGORY, + message: + 'The browser tool requires a Container build to run. In a CodeZip (Lambda-style) runtime the ' + + 'Playwright node driver cannot be executed and the tool will fail at invocation time.\n\n' + + 'Re-export with `--build Container` to include browser tool support:\n\n' + + ` agentcore export harness --name ${spec.name} --target-agent-name ${agentName} --build Container`, + }); + } else { + const browserTool = spec.tools.find(t => t.type === 'agentcore_browser'); + const customArn = + browserTool?.config && 'agentCoreBrowser' in browserTool.config + ? (browserTool.config as { agentCoreBrowser: { browserArn?: string } }).agentCoreBrowser.browserArn + : undefined; + const resource = customArn ?? `arn:*:bedrock-agentcore:\${Stack.of(this).region}:aws:browser/*`; + context.exportNotes.push({ + category: BROWSER_IAM_POLICY_NOTE_CATEGORY, + message: + `The exported runtime execution role is not automatically granted permission to use the browser tool.\n\n` + + `Add the following to agentcore/cdk/lib/cdk-stack.ts after \`this.application\` is created:\n\n` + + ` const agentEnv = this.application.environments.get('${agentName}');\n` + + ` agentEnv?.runtime.role.addToPrincipalPolicy(\n` + + ` new iam.PolicyStatement({\n` + + ` actions: [\n` + + ` 'bedrock-agentcore:StartBrowserSession',\n` + + ` 'bedrock-agentcore:StopBrowserSession',\n` + + ` 'bedrock-agentcore:GetBrowserSession',\n` + + ` 'bedrock-agentcore:ListBrowserSessions',\n` + + ` 'bedrock-agentcore:UpdateBrowserStream',\n` + + ` 'bedrock-agentcore:ConnectBrowserAutomationStream',\n` + + ` 'bedrock-agentcore:ConnectBrowserLiveViewStream',\n` + + ` ],\n` + + ` resources: [\`${resource}\`],\n` + + ` })\n` + + ` );`, + }); + } + } + + if (isToolIncluded('agentcore_code_interpreter', spec, allowedToolPatterns)) { + const ciTool = spec.tools.find(t => t.type === 'agentcore_code_interpreter'); + const customArn = + ciTool?.config && 'agentCoreCodeInterpreter' in ciTool.config + ? (ciTool.config as { agentCoreCodeInterpreter: { codeInterpreterArn?: string } }).agentCoreCodeInterpreter + .codeInterpreterArn + : undefined; + const resource = customArn ?? `arn:*:bedrock-agentcore:\${Stack.of(this).region}:aws:code-interpreter/*`; + context.exportNotes.push({ + category: CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY, + message: + `The exported runtime execution role is not automatically granted permission to use the code interpreter tool.\n\n` + + `Add the following to agentcore/cdk/lib/cdk-stack.ts after \`this.application\` is created:\n\n` + + ` const agentEnv = this.application.environments.get('${agentName}');\n` + + ` agentEnv?.runtime.role.addToPrincipalPolicy(\n` + + ` new iam.PolicyStatement({\n` + + ` actions: [\n` + + ` 'bedrock-agentcore:StartCodeInterpreterSession',\n` + + ` 'bedrock-agentcore:StopCodeInterpreterSession',\n` + + ` 'bedrock-agentcore:GetCodeInterpreterSession',\n` + + ` 'bedrock-agentcore:ListCodeInterpreterSessions',\n` + + ` 'bedrock-agentcore:InvokeCodeInterpreter',\n` + + ` ],\n` + + ` resources: [\`${resource}\`],\n` + + ` })\n` + + ` );`, + }); + } +} + +function fnmatch(pattern: string, str: string): boolean { + const re = new RegExp( + '^' + + pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.') + + '$' + ); + return re.test(str); +} diff --git a/src/cli/commands/export/harness-resolver.ts b/src/cli/commands/export/harness-resolver.ts new file mode 100644 index 000000000..c88a3f1bd --- /dev/null +++ b/src/cli/commands/export/harness-resolver.ts @@ -0,0 +1,95 @@ +import { ConfigIO, requireConfigRoot } from '../../../lib'; +import { ValidationError } from '../../../lib/errors/types'; +import type { DeployedResourceState } from '../../../schema'; +import { DEFAULT_SYSTEM_PROMPT } from './constants'; +import type { ResolvedHarnessContext } from './types'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Read and validate all on-disk inputs for the harness export. + * Throws ValidationError for user-fixable problems. + */ +export async function resolveHarnessContext( + harnessName: string, + targetAgentName: string, + configBaseDir?: string +): Promise { + const baseDir = configBaseDir ?? requireConfigRoot(); + const configIO = new ConfigIO({ baseDir }); + const projectRoot = join(baseDir, '..'); + + // 1. Read project spec and validate harness exists before any harness file I/O + const projectSpec = await configIO.readProjectSpec(); + + const harnessEntry = projectSpec.harnesses?.find(h => h.name === harnessName); + if (!harnessEntry) { + throw new ValidationError( + `Harness "${harnessName}" not found in agentcore.json. Available harnesses: ${(projectSpec.harnesses ?? []).map(h => h.name).join(', ') || 'none'}` + ); + } + + // 2. Validate target agent name not already taken + if (projectSpec.runtimes.some(r => r.name === targetAgentName)) { + throw new ValidationError( + `A runtime agent named "${targetAgentName}" already exists. Choose a different --target-agent-name.` + ); + } + + // 2b. Validate the target directory does not already exist on disk. A leftover directory + // (e.g. from a removed agent or a prior failed export) has no runtime entry, so the + // check above would pass and the render/copy would silently overwrite it. + const targetAgentDir = join(projectRoot, 'app', targetAgentName); + if (existsSync(targetAgentDir)) { + throw new ValidationError( + `The directory "app/${targetAgentName}/" already exists. Remove it or choose a different --target-agent-name.` + ); + } + + // 3. Read harness spec + const spec = await configIO.readHarnessSpec(harnessName); + + // 4. Read system prompt — harness app files live in projectRoot/app// + const harnessDir = join(projectRoot, 'app', harnessName); + const systemPromptPath = join(harnessDir, 'system-prompt.md'); + let systemPrompt: string; + if (existsSync(systemPromptPath)) { + systemPrompt = readFileSync(systemPromptPath, 'utf8').trim(); + } else if (spec.systemPrompt) { + systemPrompt = spec.systemPrompt; + } else { + systemPrompt = DEFAULT_SYSTEM_PROMPT; + } + + // 5. Read deployed state (optional — absent before first deploy) + let deployedResources: DeployedResourceState | null = null; + let region: string | undefined; + try { + const deployedState = await configIO.readDeployedState(); + // Use the first target's resources (there is only one target per project) + const firstTarget = Object.values(deployedState.targets)[0]; + deployedResources = firstTarget?.resources ?? null; + } catch { + // File absent or unreadable — proceed without it + } + + try { + const targets = await configIO.readAWSDeploymentTargets(); + region = targets[0]?.region; + } catch { + // No targets configured yet + } + + return { + harnessName, + targetAgentName, + spec, + systemPrompt, + projectSpec, + deployedResources, + configBaseDir: baseDir, + projectRoot, + exportNotes: [], + region, + }; +} diff --git a/src/cli/commands/export/index.ts b/src/cli/commands/export/index.ts new file mode 100644 index 000000000..24587eb82 --- /dev/null +++ b/src/cli/commands/export/index.ts @@ -0,0 +1,83 @@ +import { serializeResult } from '../../../lib/result'; +import { ANSI, COMMAND_DESCRIPTIONS } from '../../constants'; +import { renderTUI } from '../../tui/render'; +import { handleExportHarness } from './harness-action'; +import type { Command } from '@commander-js/extra-typings'; + +const { green, red, cyan, dim, reset } = ANSI; + +export function registerExport(program: Command): void { + const exportCmd = program + .command('export') + .description(COMMAND_DESCRIPTIONS.export) + .showHelpAfterError() + .showSuggestionAfterError(); + + exportCmd + .command('harness') + .description('Export an in-project harness to a Python Strands runtime agent') + .option('--name ', 'Harness name [non-interactive]') + .option( + '--target-agent-name ', + 'Name for the generated runtime agent (default: Agent) [non-interactive]' + ) + .option('--build ', 'Build type: CodeZip or Container [non-interactive]') + .option('--json', 'Output results as JSON') + .action(async options => { + if (!options.name) { + if (options.json) { + console.log(JSON.stringify({ success: false, error: '--name is required in non-interactive mode' })); + process.exit(1); + } + await renderTUI({ initialRoute: { name: 'export-harness' }, actionOnBack: 'exit' }); + return; + } + + const steps: string[] = []; + let result: Awaited>; + try { + result = await handleExportHarness(options, { + onProgress: (message: string) => { + if (options.json) return; + steps.push(message); + console.log(`${green}[done]${reset} ${message}`); + }, + }); + } catch (err) { + if (options.json) { + console.log( + JSON.stringify({ success: false, error: { message: err instanceof Error ? err.message : String(err) } }) + ); + process.exit(1); + } + throw err; + } + + if (options.json) { + console.log(JSON.stringify(serializeResult(result))); + if (!result.success) process.exit(1); + return; + } + + if (!result.success) { + console.error(`\n${red}[error]${reset} Export failed: ${result.error.message}`); + process.exit(1); + } + + const targetAgentName = options.targetAgentName ?? `${options.name}Agent`; + + console.log(''); + console.log(`${green}Exported harness ${options.name} → runtime agent ${targetAgentName}${reset}`); + console.log(''); + console.log(`${dim}Generated:${reset}`); + console.log(` app/${targetAgentName}/ Python agent (Strands)`); + console.log(` agentcore/agentcore.json updated`); + console.log(` EXPORT_NOTES.md review for manual follow-up items`); + console.log(''); + console.log('Next steps:'); + console.log(''); + console.log(` ${cyan}agentcore deploy${reset} ${dim}Deploy the new runtime agent${reset}`); + console.log(` ${cyan}agentcore dev${reset} ${dim}Run the agent locally${reset}`); + console.log(''); + }); +} diff --git a/src/cli/commands/export/types.ts b/src/cli/commands/export/types.ts new file mode 100644 index 000000000..188f9d63b --- /dev/null +++ b/src/cli/commands/export/types.ts @@ -0,0 +1,78 @@ +import type { AgentCoreProjectSpec, Credential, DeployedResourceState, HarnessSpec } from '../../../schema'; +import type { + AgentRenderConfig, + GatewayProviderRenderConfig, + IdentityProviderRenderConfig, + MemoryProviderRenderConfig, +} from '../../templates/types'; + +// ============================================================================ +// CLI options +// ============================================================================ + +export interface ExportHarnessOptions { + name?: string; + targetAgentName?: string; + build?: string; + json?: boolean; +} + +// ============================================================================ +// Resolved context (all on-disk reads done before mapping) +// ============================================================================ + +export interface ResolvedHarnessContext { + harnessName: string; + targetAgentName: string; + spec: HarnessSpec; + systemPrompt: string; + projectSpec: AgentCoreProjectSpec; + /** First target's resources from deployed-state.json, or null when file absent */ + deployedResources: DeployedResourceState | null; + configBaseDir: string; + projectRoot: string; + exportNotes: ExportNote[]; + /** AWS region from the first deployment target, or undefined if not configured */ + region?: string; +} + +// ============================================================================ +// Export notes (collected during mapping, written to EXPORT_NOTES.md) +// ============================================================================ + +export interface ExportNote { + category: string; + message: string; +} + +// ============================================================================ +// Mapping output +// ============================================================================ + +export interface HarnessMappingResult { + renderConfig: AgentRenderConfig; + agentEnvSpec: import('../../../schema').AgentEnvSpec; + /** Model identity credential, if any */ + credentialEntry: Credential | null; + /** One credential entry per MCP header that carries a secret value */ + mcpCredentialEntries: { credential: Credential; envVarName: string; value: string }[]; +} + +// ============================================================================ +// Resolved sub-objects (internal to mapper) +// ============================================================================ + +export interface ResolvedGatewayProvider extends GatewayProviderRenderConfig { + /** True when the gateway was found in this project's deployed state */ + isSameProject: boolean; + /** Hardcoded URL used when gateway is external (not in deployed state) */ + hardcodedUrl?: string; +} + +export interface ResolvedMemoryProvider extends MemoryProviderRenderConfig { + isSameProject: boolean; +} + +export interface ResolvedIdentityProvider extends IdentityProviderRenderConfig { + credentialEntry: Credential | null; +} 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 153ead77a..02628376d 100644 --- a/src/cli/commands/import/__tests__/import-gateway-flow.test.ts +++ b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts @@ -176,12 +176,27 @@ function setupCommonMocks() { // ── Tests ──────────────────────────────────────────────────────────────────── describe('handleImportGateway', () => { + let originalAwsRegion: string | undefined; + let originalAwsDefaultRegion: string | undefined; + beforeEach(() => { + originalAwsRegion = process.env.AWS_REGION; + originalAwsDefaultRegion = process.env.AWS_DEFAULT_REGION; + process.env.AWS_REGION = REGION; + delete process.env.AWS_DEFAULT_REGION; vi.clearAllMocks(); setupCommonMocks(); }); afterEach(() => { + if (originalAwsRegion !== undefined) { + process.env.AWS_REGION = originalAwsRegion; + } else { + delete process.env.AWS_REGION; + } + if (originalAwsDefaultRegion !== undefined) { + process.env.AWS_DEFAULT_REGION = originalAwsDefaultRegion; + } vi.restoreAllMocks(); }); diff --git a/src/cli/commands/import/__tests__/import-gateway-spec.test.ts b/src/cli/commands/import/__tests__/import-gateway-spec.test.ts index 7c2963edf..2df382f8c 100644 --- a/src/cli/commands/import/__tests__/import-gateway-spec.test.ts +++ b/src/cli/commands/import/__tests__/import-gateway-spec.test.ts @@ -35,7 +35,7 @@ const emptyTargets: AgentCoreGatewayTarget[] = []; describe('toGatewaySpec – authorizer type mapping', () => { it('NONE authorizerType: no authorizerConfiguration in output', () => { const gw = makeGateway({ authorizerType: 'NONE' }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.authorizerType).toBe('NONE'); expect(result).not.toHaveProperty('authorizerConfiguration'); @@ -43,7 +43,7 @@ describe('toGatewaySpec – authorizer type mapping', () => { it('AWS_IAM authorizerType: maps to AWS_IAM, no authorizerConfiguration', () => { const gw = makeGateway({ authorizerType: 'AWS_IAM' }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.authorizerType).toBe('AWS_IAM'); expect(result).not.toHaveProperty('authorizerConfiguration'); @@ -61,7 +61,7 @@ describe('toGatewaySpec – authorizer type mapping', () => { }, }, }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.authorizerType).toBe('CUSTOM_JWT'); expect(result.authorizerConfiguration).toBeDefined(); @@ -100,7 +100,7 @@ describe('toGatewaySpec – authorizer type mapping', () => { }, }, }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); const claims = result.authorizerConfiguration!.customJwtAuthorizer!.customClaims!; expect(claims).toHaveLength(2); @@ -130,7 +130,7 @@ describe('toGatewaySpec – authorizer type mapping', () => { }, }, }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); const jwt = result.authorizerConfiguration!.customJwtAuthorizer!; expect(jwt).not.toHaveProperty('allowedAudience'); @@ -141,9 +141,8 @@ describe('toGatewaySpec – authorizer type mapping', () => { it('missing authorizerType: defaults to NONE', () => { const gw = makeGateway(); // Simulate undefined authorizerType by deleting after construction - // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (gw as any).authorizerType; - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.authorizerType).toBe('NONE'); expect(result).not.toHaveProperty('authorizerConfiguration'); @@ -159,7 +158,7 @@ describe('toGatewaySpec – semantic search', () => { const gw = makeGateway({ protocolConfiguration: { mcp: { searchType: 'SEMANTIC' } }, }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.enableSemanticSearch).toBe(true); }); @@ -168,14 +167,14 @@ describe('toGatewaySpec – semantic search', () => { const gw = makeGateway({ protocolConfiguration: { mcp: { searchType: 'KEYWORD' } }, }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.enableSemanticSearch).toBe(false); }); it('protocolConfiguration missing: enableSemanticSearch is false', () => { const gw = makeGateway(); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.enableSemanticSearch).toBe(false); }); @@ -188,21 +187,21 @@ describe('toGatewaySpec – semantic search', () => { describe('toGatewaySpec – exception level', () => { it('exceptionLevel=DEBUG: maps to DEBUG', () => { const gw = makeGateway({ exceptionLevel: 'DEBUG' }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.exceptionLevel).toBe('DEBUG'); }); it('exceptionLevel undefined: maps to NONE', () => { const gw = makeGateway({ exceptionLevel: undefined }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.exceptionLevel).toBe('NONE'); }); it('exceptionLevel other value: maps to NONE', () => { const gw = makeGateway({ exceptionLevel: 'VERBOSE' }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.exceptionLevel).toBe('NONE'); }); @@ -220,7 +219,7 @@ describe('toGatewaySpec – policy engine', () => { mode: 'ENFORCE', }, }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.policyEngineConfiguration).toBeDefined(); expect(result.policyEngineConfiguration!.policyEngineName).toBe('my_policy_engine'); @@ -229,7 +228,7 @@ describe('toGatewaySpec – policy engine', () => { it('policyEngineConfiguration absent: field omitted', () => { const gw = makeGateway(); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result).not.toHaveProperty('policyEngineConfiguration'); }); @@ -242,7 +241,7 @@ describe('toGatewaySpec – policy engine', () => { describe('toGatewaySpec – other fields', () => { it('resourceName is always set to gateway.name', () => { const gw = makeGateway({ name: 'AwsGatewayName' }); - const result = toGatewaySpec(gw, emptyTargets, 'local_name'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'local_name' }); expect(result.resourceName).toBe('AwsGatewayName'); expect(result.name).toBe('local_name'); @@ -250,49 +249,49 @@ describe('toGatewaySpec – other fields', () => { it('description present: included in output', () => { const gw = makeGateway({ description: 'My gateway description' }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.description).toBe('My gateway description'); }); it('description undefined: omitted from output', () => { const gw = makeGateway({ description: undefined }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result).not.toHaveProperty('description'); }); it('tags present with entries: included in output', () => { const gw = makeGateway({ tags: { env: 'prod', team: 'platform' } }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.tags).toEqual({ env: 'prod', team: 'platform' }); }); it('tags empty object: omitted from output', () => { const gw = makeGateway({ tags: {} }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result).not.toHaveProperty('tags'); }); it('tags undefined: omitted from output', () => { const gw = makeGateway({ tags: undefined }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result).not.toHaveProperty('tags'); }); it('executionRoleArn: mapped from gateway.roleArn', () => { const gw = makeGateway({ roleArn: 'arn:aws:iam::123456789012:role/GatewayRole' }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result.executionRoleArn).toBe('arn:aws:iam::123456789012:role/GatewayRole'); }); it('roleArn undefined: executionRoleArn omitted from output', () => { const gw = makeGateway({ roleArn: undefined }); - const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets: emptyTargets, localName: 'my_gw' }); expect(result).not.toHaveProperty('executionRoleArn'); }); @@ -302,7 +301,7 @@ describe('toGatewaySpec – other fields', () => { { name: 'target1', targetType: 'mcpServer', endpoint: 'https://mcp.example.com' }, ]; const gw = makeGateway(); - const result = toGatewaySpec(gw, targets, 'my_gw'); + const result = toGatewaySpec({ gateway: gw, targets, localName: 'my_gw' }); expect(result.targets).toBe(targets); expect(result.targets).toHaveLength(1); diff --git a/src/cli/commands/import/__tests__/import-gateway-targets.test.ts b/src/cli/commands/import/__tests__/import-gateway-targets.test.ts deleted file mode 100644 index 98284c297..000000000 --- a/src/cli/commands/import/__tests__/import-gateway-targets.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -/** - * Import Gateway Target Mapping Unit Tests - * - * Covers toGatewayTargetSpec for non-mcpServer target types: - * - apiGateway: toolFilters, toolOverrides, outboundAuth - * - openApiSchema: S3 URI mapping, missing URI warning - * - smithyModel: S3 URI mapping, missing URI warning - * - lambda: lambdaFunctionArn mapping, missing ARN, inline-only schema - * - Unrecognized target type - */ -import type { GatewayTargetDetail } from '../../../aws/agentcore-control'; -import { toGatewayTargetSpec } from '../import-gateway'; -import { assert, describe, expect, it, vi } from 'vitest'; - -/** Helper to build a minimal GatewayTargetDetail with only the fields under test. */ -function baseDetail(overrides: Partial = {}): GatewayTargetDetail { - return { - targetId: 'tgt-001', - name: 'test_target', - status: 'READY', - ...overrides, - }; -} - -// ============================================================================ -// apiGateway target -// ============================================================================ - -describe('toGatewayTargetSpec — apiGateway', () => { - it('maps restApiId, stage, and toolFilters correctly', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: { - apiGateway: { - restApiId: 'abc123', - stage: 'prod', - apiGatewayToolConfiguration: { - toolFilters: [ - { filterPath: '/pets', methods: ['GET', 'POST'] }, - { filterPath: '/users', methods: ['GET'] }, - ], - }, - }, - }, - }, - }); - - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, new Map(), onProgress); - - assert(result.success); - expect(result.target!.name).toBe('test_target'); - expect(result.target!.targetType).toBe('apiGateway'); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const apigw = (result.target as any).apiGateway; - expect(apigw.restApiId).toBe('abc123'); - expect(apigw.stage).toBe('prod'); - expect(apigw.apiGatewayToolConfiguration.toolFilters).toEqual([ - { filterPath: '/pets', methods: ['GET', 'POST'] }, - { filterPath: '/users', methods: ['GET'] }, - ]); - }); - - it('maps toolOverrides when present', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: { - apiGateway: { - restApiId: 'abc123', - stage: 'prod', - apiGatewayToolConfiguration: { - toolFilters: [], - toolOverrides: [ - { name: 'listPets', path: '/pets', method: 'GET', description: 'List all pets' }, - { name: 'createPet', path: '/pets', method: 'POST' }, - ], - }, - }, - }, - }, - }); - - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, new Map(), onProgress); - - assert(result.success); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const apigw = (result.target as any).apiGateway; - expect(apigw.apiGatewayToolConfiguration.toolOverrides).toEqual([ - { name: 'listPets', path: '/pets', method: 'GET', description: 'List all pets' }, - { name: 'createPet', path: '/pets', method: 'POST' }, - ]); - }); - - it('omits toolOverrides when not present', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: { - apiGateway: { - restApiId: 'abc123', - stage: 'prod', - apiGatewayToolConfiguration: { - toolFilters: [{ filterPath: '/pets', methods: ['GET'] }], - }, - }, - }, - }, - }); - - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, new Map(), onProgress); - - assert(result.success); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const apigw = (result.target as any).apiGateway; - expect(apigw.apiGatewayToolConfiguration.toolOverrides).toBeUndefined(); - }); - - it('returns outboundAuth when OAuth credential is configured', () => { - const providerArn = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:credential-provider/cred-001'; - const detail = baseDetail({ - targetConfiguration: { - mcp: { - apiGateway: { - restApiId: 'abc123', - stage: 'prod', - apiGatewayToolConfiguration: { toolFilters: [] }, - }, - }, - }, - credentialProviderConfigurations: [ - { - credentialProviderType: 'OAUTH', - credentialProvider: { - oauthCredentialProvider: { - providerArn, - scopes: ['read', 'write'], - }, - }, - }, - ], - }); - - const credentials = new Map([[providerArn, 'my_oauth_cred']]); - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, credentials, onProgress); - - assert(result.success); - expect(result.target!.outboundAuth).toEqual({ - type: 'OAUTH', - credentialName: 'my_oauth_cred', - scopes: ['read', 'write'], - }); - }); -}); - -// ============================================================================ -// openApiSchema target -// ============================================================================ - -describe('toGatewayTargetSpec — openApiSchema', () => { - it('maps S3 URI and bucketOwnerAccountId correctly', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: { - openApiSchema: { - s3: { uri: 's3://my-bucket/schema.yaml', bucketOwnerAccountId: '123456789012' }, - }, - }, - }, - }); - - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, new Map(), onProgress); - - assert(result.success); - expect(result.target!.name).toBe('test_target'); - expect(result.target!.targetType).toBe('openApiSchema'); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const schemaSource = (result.target as any).schemaSource; - expect(schemaSource.s3.uri).toBe('s3://my-bucket/schema.yaml'); - expect(schemaSource.s3.bucketOwnerAccountId).toBe('123456789012'); - }); - - it('returns undefined and emits warning when S3 URI is missing', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: { - openApiSchema: { inlinePayload: '{"openapi":"3.0.0"}' }, - }, - }, - }); - - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, new Map(), onProgress); - - assert(result.success); - expect(result.target).toBeUndefined(); - expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(openApiSchema) has no S3 URI, skipping')); - }); -}); - -// ============================================================================ -// smithyModel target -// ============================================================================ - -describe('toGatewayTargetSpec — smithyModel', () => { - it('maps S3 URI correctly', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: { - smithyModel: { - s3: { uri: 's3://models-bucket/model.json' }, - }, - }, - }, - }); - - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, new Map(), onProgress); - - assert(result.success); - expect(result.target!.name).toBe('test_target'); - expect(result.target!.targetType).toBe('smithyModel'); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const schemaSource = (result.target as any).schemaSource; - expect(schemaSource.s3.uri).toBe('s3://models-bucket/model.json'); - expect(schemaSource.s3.bucketOwnerAccountId).toBeUndefined(); - }); - - it('returns undefined and emits warning when S3 URI is missing', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: { - smithyModel: { inlinePayload: '{"smithy":"1.0"}' }, - }, - }, - }); - - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, new Map(), onProgress); - - assert(result.success); - expect(result.target).toBeUndefined(); - expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(smithyModel) has no S3 URI, skipping')); - }); -}); - -// ============================================================================ -// lambda target -// ============================================================================ - -describe('toGatewayTargetSpec — lambda', () => { - it('maps lambda with S3 tool schema to lambdaFunctionArn type', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: { - lambda: { - lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', - toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, - }, - }, - }, - }); - - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, new Map(), onProgress); - - assert(result.success); - expect(result.target!.name).toBe('test_target'); - expect(result.target!.targetType).toBe('lambdaFunctionArn'); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const lambdaConfig = (result.target as any).lambdaFunctionArn; - expect(lambdaConfig.lambdaArn).toBe('arn:aws:lambda:us-west-2:123456789012:function:my-func'); - expect(lambdaConfig.toolSchemaFile).toBe('s3://schemas/tools.json'); - }); - - it('returns undefined and emits warning when lambdaArn is missing', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: { - lambda: { - lambdaArn: '', - toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, - }, - }, - }, - }); - - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, new Map(), onProgress); - - assert(result.success); - expect(result.target).toBeUndefined(); - expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(lambda) has no ARN, skipping')); - }); - - it('returns undefined and emits warning when lambda has inline schema only', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: { - lambda: { - lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', - toolSchema: { inlinePayload: '{"tools":[]}' }, - }, - }, - }, - }); - - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, new Map(), onProgress); - - assert(result.success); - expect(result.target).toBeUndefined(); - expect(onProgress).toHaveBeenCalledWith( - expect.stringContaining('has inline tool schema, which cannot be imported') - ); - }); - - it('emits progress message for successful lambda mapping', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: { - lambda: { - lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', - toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, - }, - }, - }, - }); - - const onProgress = vi.fn(); - toGatewayTargetSpec(detail, new Map(), onProgress); - - expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('Mapping compute-backed Lambda target')); - }); -}); - -// ============================================================================ -// Unrecognized target type -// ============================================================================ - -describe('toGatewayTargetSpec — unrecognized target type', () => { - it('returns undefined and emits warning when no known mcp type matches', () => { - const detail = baseDetail({ - targetConfiguration: { - mcp: {}, - }, - }); - - const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, new Map(), onProgress); - - assert(result.success); - expect(result.target).toBeUndefined(); - expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('unrecognized target type')); - }); -}); diff --git a/src/cli/commands/import/__tests__/import-gateway.test.ts b/src/cli/commands/import/__tests__/import-gateway.test.ts index eb7c67dd6..837993428 100644 --- a/src/cli/commands/import/__tests__/import-gateway.test.ts +++ b/src/cli/commands/import/__tests__/import-gateway.test.ts @@ -6,7 +6,7 @@ import { _resolveOutboundAuth as resolveOutboundAuth, _toGatewayTargetSpec as toGatewayTargetSpec, } from '../import-gateway'; -import { assert, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; // ============================================================================ // Helpers @@ -39,13 +39,12 @@ describe('toGatewayTargetSpec — mcpServer targets', () => { const result = toGatewayTargetSpec(detail, credentials, onProgress); - assert(result.success); - expect(result.target).toEqual({ + expect(result).toEqual({ name: 'my-mcp-target', targetType: 'mcpServer', endpoint: 'https://example.com/mcp', }); - expect(result.target).not.toHaveProperty('outboundAuth'); + expect(result).not.toHaveProperty('outboundAuth'); expect(onProgress).not.toHaveBeenCalled(); }); @@ -74,8 +73,7 @@ describe('toGatewayTargetSpec — mcpServer targets', () => { const result = toGatewayTargetSpec(detail, credentials, onProgress); - assert(result.success); - expect(result.target).toEqual({ + expect(result).toEqual({ name: 'my-mcp-target', targetType: 'mcpServer', endpoint: 'https://example.com/mcp', @@ -111,8 +109,7 @@ describe('toGatewayTargetSpec — mcpServer targets', () => { const result = toGatewayTargetSpec(detail, credentials, onProgress); - assert(result.success); - expect(result.target).toEqual({ + expect(result).toEqual({ name: 'my-mcp-target', targetType: 'mcpServer', endpoint: 'https://example.com/mcp', @@ -123,7 +120,7 @@ describe('toGatewayTargetSpec — mcpServer targets', () => { }); }); - it('returns failure when OAuth credential not found in project', () => { + it('throws when OAuth credential not found in project', () => { const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/missing-oauth'; const detail = makeDetail({ targetConfiguration: { @@ -146,12 +143,12 @@ describe('toGatewayTargetSpec — mcpServer targets', () => { const credentials = new Map(); const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, credentials, onProgress); - assert(!result.success); - expect(result.error.message).toContain('uses an OAuth credential provider not found'); + expect(() => toGatewayTargetSpec(detail, credentials, onProgress)).toThrow( + 'uses an OAuth credential provider not found' + ); }); - it('returns failure when API_KEY credential not found in project', () => { + it('throws when API_KEY credential not found in project', () => { const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/missing-apikey'; const detail = makeDetail({ targetConfiguration: { @@ -173,9 +170,9 @@ describe('toGatewayTargetSpec — mcpServer targets', () => { const credentials = new Map(); const onProgress = vi.fn(); - const result = toGatewayTargetSpec(detail, credentials, onProgress); - assert(!result.success); - expect(result.error.message).toContain('uses an API Key credential provider not found'); + expect(() => toGatewayTargetSpec(detail, credentials, onProgress)).toThrow( + 'uses an API Key credential provider not found' + ); }); it('returns undefined and warns when target has no MCP configuration', () => { @@ -187,9 +184,8 @@ describe('toGatewayTargetSpec — mcpServer targets', () => { const result = toGatewayTargetSpec(detail, credentials, onProgress); - assert(result.success); - expect(result.target).toBeUndefined(); - expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('no MCP configuration')); + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('no MCP or HTTP configuration')); }); }); @@ -218,8 +214,7 @@ describe('resolveOutboundAuth — scopes handling', () => { const result = resolveOutboundAuth(detail, credentials, onProgress); - assert(result.success); - expect(result.auth).toEqual({ + expect(result).toEqual({ type: 'OAUTH', credentialName: 'scoped-cred', scopes: ['openid', 'profile', 'email'], @@ -246,11 +241,10 @@ describe('resolveOutboundAuth — scopes handling', () => { const result = resolveOutboundAuth(detail, credentials, onProgress); - assert(result.success); - expect(result.auth).toEqual({ + expect(result).toEqual({ type: 'OAUTH', credentialName: 'no-scope-cred', }); - expect(result.auth).not.toHaveProperty('scopes'); + expect(result).not.toHaveProperty('scopes'); }); }); diff --git a/src/cli/commands/import/__tests__/import-no-deploy.test.ts b/src/cli/commands/import/__tests__/import-no-deploy.test.ts index 001c551be..1bf8d5ebc 100644 --- a/src/cli/commands/import/__tests__/import-no-deploy.test.ts +++ b/src/cli/commands/import/__tests__/import-no-deploy.test.ts @@ -464,6 +464,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }) ); @@ -482,6 +483,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -508,6 +510,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -532,6 +535,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -553,6 +557,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -572,6 +577,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -590,6 +596,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -609,6 +616,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -627,6 +635,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -644,6 +653,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -680,6 +690,7 @@ describe('handleImport: target resolution with null account/region', () => { version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }) ); @@ -722,6 +733,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -768,6 +780,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -812,6 +825,7 @@ agents: version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); diff --git a/src/cli/commands/import/__tests__/import-online-eval.test.ts b/src/cli/commands/import/__tests__/import-online-eval.test.ts index f3b454494..f98eb71ff 100644 --- a/src/cli/commands/import/__tests__/import-online-eval.test.ts +++ b/src/cli/commands/import/__tests__/import-online-eval.test.ts @@ -144,8 +144,8 @@ describe('toOnlineEvalConfigSpec', () => { assert(result.success); expect(result.config.evaluators).toHaveLength(2); - expect(result.config.evaluators[0]).toBe('local_eval'); - expect(result.config.evaluators[1]).toMatch(/^arn:/); + expect(result.config.evaluators![0]).toBe('local_eval'); + expect(result.config.evaluators![1]).toMatch(/^arn:/); }); }); diff --git a/src/cli/commands/import/__tests__/import-runtime-handler.test.ts b/src/cli/commands/import/__tests__/import-runtime-handler.test.ts index d8926feb9..799663e38 100644 --- a/src/cli/commands/import/__tests__/import-runtime-handler.test.ts +++ b/src/cli/commands/import/__tests__/import-runtime-handler.test.ts @@ -116,6 +116,7 @@ const defaultProjectSpec = { version: 1, runtimes: [], memories: [], + knowledgeBases: [], evaluators: [], onlineEvalConfigs: [], }; diff --git a/src/cli/commands/import/__tests__/jwt-authorizer.test.ts b/src/cli/commands/import/__tests__/jwt-authorizer.test.ts index 1da1bb33a..7ef187ebf 100644 --- a/src/cli/commands/import/__tests__/jwt-authorizer.test.ts +++ b/src/cli/commands/import/__tests__/jwt-authorizer.test.ts @@ -250,7 +250,7 @@ describe('handleImport: JWT authorizer passthrough', () => { fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync( path.join(configDir, 'agentcore.json'), - JSON.stringify({ name: 'myproject', version: 1, runtimes: [], memories: [], credentials: [] }) + JSON.stringify({ name: 'myproject', version: 1, runtimes: [], memories: [], knowledgeBases: [], credentials: [] }) ); mockFindConfigRoot.mockReturnValue(configDir); }); @@ -265,6 +265,7 @@ describe('handleImport: JWT authorizer passthrough', () => { version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -293,6 +294,7 @@ describe('handleImport: JWT authorizer passthrough', () => { version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); @@ -315,6 +317,7 @@ describe('handleImport: JWT authorizer passthrough', () => { version: 1, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], }); mockWriteProjectSpec.mockResolvedValue(undefined); diff --git a/src/cli/commands/import/constants.ts b/src/cli/commands/import/constants.ts index 35149fead..6219d278c 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -11,6 +11,7 @@ export const CFN_RESOURCE_IDENTIFIERS: Record = { 'AWS::BedrockAgentCore::GatewayTarget': ['GatewayIdentifier', 'TargetId'], 'AWS::BedrockAgentCore::Evaluator': ['EvaluatorId'], 'AWS::BedrockAgentCore::OnlineEvaluationConfig': ['OnlineEvaluationConfigId'], + 'AWS::BedrockAgentCore::Harness': ['HarnessId'], }; /** @@ -31,6 +32,7 @@ export const PRIMARY_RESOURCE_TYPES = [ 'AWS::BedrockAgentCore::CodeInterpreterCustom', 'AWS::BedrockAgentCore::Policy', 'AWS::BedrockAgentCore::PolicyEngine', + 'AWS::BedrockAgentCore::Harness', ]; /** diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts index d50f14fdd..163a6ede2 100644 --- a/src/cli/commands/import/import-gateway.ts +++ b/src/cli/commands/import/import-gateway.ts @@ -1,5 +1,4 @@ -import { ValidationError, toError } from '../../../lib'; -import { type Result, failureResult } from '../../../lib/result.js'; +import { toError } from '../../../lib'; import type { AgentCoreGateway, AgentCoreGatewayTarget, @@ -45,28 +44,50 @@ import type { Command } from '@commander-js/extra-typings'; function toGatewayTargetSpec( detail: GatewayTargetDetail, credentials: Map, - onProgress: (msg: string) => void -): Result<{ target: AgentCoreGatewayTarget | undefined }> { + onProgress: (msg: string) => void, + runtimeArnToName?: Map +): AgentCoreGatewayTarget | undefined { + // Handle HTTP runtime targets + const http = detail.targetConfiguration?.http; + if (http) { + const runtimeConfig = http.agentcoreRuntime ?? http.runtimeTargetConfiguration; + if (runtimeConfig) { + const managedName = runtimeArnToName?.get(runtimeConfig.runtimeArn); + if (!managedName) { + onProgress( + `Error: Target "${detail.name}" references runtime "${runtimeConfig.runtimeArn}" which is not managed by this project. ` + + `Import the runtime first: agentcore import runtime --arn ${runtimeConfig.runtimeArn}` + ); + return undefined; + } + return { + name: detail.name, + targetType: 'httpRuntime', + httpRuntime: { + runtime: managedName, + ...(runtimeConfig.qualifier && { runtimeEndpoint: runtimeConfig.qualifier }), + }, + }; + } + onProgress(`Warning: Target "${detail.name}" has HTTP configuration but no runtime, skipping`); + return undefined; + } + const mcp = detail.targetConfiguration?.mcp; if (!mcp) { - onProgress(`Warning: Target "${detail.name}" has no MCP configuration, skipping`); - return { success: true, target: undefined }; + onProgress(`Warning: Target "${detail.name}" has no MCP or HTTP configuration, skipping`); + return undefined; } - const authResult = resolveOutboundAuth(detail, credentials, onProgress); - if (!authResult.success) return authResult; - const outboundAuth = authResult.auth; + const outboundAuth = resolveOutboundAuth(detail, credentials, onProgress); // MCP Server (external endpoint) if (mcp.mcpServer) { return { - success: true, - target: { - name: detail.name, - targetType: 'mcpServer', - endpoint: mcp.mcpServer.endpoint, - ...(outboundAuth && { outboundAuth }), - }, + name: detail.name, + targetType: 'mcpServer', + endpoint: mcp.mcpServer.endpoint, + ...(outboundAuth && { outboundAuth }), }; } @@ -98,7 +119,7 @@ function toGatewayTargetSpec( ...(outboundAuth && { outboundAuth }), }; /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ - return { success: true, target }; + return target; } // OpenAPI Schema @@ -106,22 +127,19 @@ function toGatewayTargetSpec( const schema = mcp.openApiSchema; if (schema.s3?.uri) { return { - success: true, - target: { - name: detail.name, - targetType: 'openApiSchema', - schemaSource: { - s3: { - uri: schema.s3.uri, - ...(schema.s3.bucketOwnerAccountId && { bucketOwnerAccountId: schema.s3.bucketOwnerAccountId }), - }, + name: detail.name, + targetType: 'openApiSchema', + schemaSource: { + s3: { + uri: schema.s3.uri, + ...(schema.s3.bucketOwnerAccountId && { bucketOwnerAccountId: schema.s3.bucketOwnerAccountId }), }, - ...(outboundAuth && { outboundAuth }), }, + ...(outboundAuth && { outboundAuth }), }; } onProgress(`Warning: Target "${detail.name}" (openApiSchema) has no S3 URI, skipping`); - return { success: true, target: undefined }; + return undefined; } // Smithy Model @@ -129,22 +147,19 @@ function toGatewayTargetSpec( const schema = mcp.smithyModel; if (schema.s3?.uri) { return { - success: true, - target: { - name: detail.name, - targetType: 'smithyModel', - schemaSource: { - s3: { - uri: schema.s3.uri, - ...(schema.s3.bucketOwnerAccountId && { bucketOwnerAccountId: schema.s3.bucketOwnerAccountId }), - }, + name: detail.name, + targetType: 'smithyModel', + schemaSource: { + s3: { + uri: schema.s3.uri, + ...(schema.s3.bucketOwnerAccountId && { bucketOwnerAccountId: schema.s3.bucketOwnerAccountId }), }, - ...(outboundAuth && { outboundAuth }), }, + ...(outboundAuth && { outboundAuth }), }; } onProgress(`Warning: Target "${detail.name}" (smithyModel) has no S3 URI, skipping`); - return { success: true, target: undefined }; + return undefined; } // Lambda (compute-backed) → map to lambdaFunctionArn @@ -152,7 +167,7 @@ function toGatewayTargetSpec( const lambdaArn = mcp.lambda.lambdaArn; if (!lambdaArn) { onProgress(`Warning: Target "${detail.name}" (lambda) has no ARN, skipping`); - return { success: true, target: undefined }; + return undefined; } // Extract tool schema S3 URI if available @@ -164,26 +179,23 @@ function toGatewayTargetSpec( if (s3Uri) { onProgress(`Mapping compute-backed Lambda target "${detail.name}" to lambdaFunctionArn type`); return { - success: true, - target: { - name: detail.name, - targetType: 'lambdaFunctionArn', - lambdaFunctionArn: { - lambdaArn, - toolSchemaFile: s3Uri, - }, - ...(outboundAuth && { outboundAuth }), + name: detail.name, + targetType: 'lambdaFunctionArn', + lambdaFunctionArn: { + lambdaArn, + toolSchemaFile: s3Uri, }, + ...(outboundAuth && { outboundAuth }), }; } // Lambda without S3 schema — can't import as lambdaFunctionArn since toolSchemaFile is required onProgress(`Warning: Target "${detail.name}" (lambda) has inline tool schema, which cannot be imported. Skipping.`); - return { success: true, target: undefined }; + return undefined; } onProgress(`Warning: Target "${detail.name}" has an unrecognized target type, skipping`); - return { success: true, target: undefined }; + return undefined; } /** @@ -193,9 +205,9 @@ function resolveOutboundAuth( detail: GatewayTargetDetail, credentials: Map, _onProgress: (msg: string) => void -): Result<{ auth: OutboundAuth | undefined }> { +): OutboundAuth | undefined { const configs = detail.credentialProviderConfigurations; - if (!configs || configs.length === 0) return { success: true, auth: undefined }; + if (!configs || configs.length === 0) return undefined; for (const config of configs) { if (config.credentialProviderType === 'OAUTH' && config.credentialProvider?.oauthCredentialProvider) { @@ -203,21 +215,16 @@ function resolveOutboundAuth( const credentialName = credentials.get(providerArn); if (credentialName) { return { - success: true, - auth: { - type: 'OAUTH', - credentialName, - ...(config.credentialProvider.oauthCredentialProvider.scopes?.length && { - scopes: config.credentialProvider.oauthCredentialProvider.scopes, - }), - }, + type: 'OAUTH', + credentialName, + ...(config.credentialProvider.oauthCredentialProvider.scopes?.length && { + scopes: config.credentialProvider.oauthCredentialProvider.scopes, + }), }; } - return failureResult( - new ValidationError( - `Target "${detail.name}" uses an OAuth credential provider not found in this project's deployed state. ` + - 'Import the credential first with `agentcore add credential` and re-run.' - ) + throw new Error( + `Target "${detail.name}" uses an OAuth credential provider not found in this project's deployed state. ` + + 'Import the credential first with `agentcore add credential` and re-run.' ); } @@ -225,31 +232,30 @@ function resolveOutboundAuth( const providerArn = config.credentialProvider.apiKeyCredentialProvider.providerArn; const credentialName = credentials.get(providerArn); if (credentialName) { - return { success: true, auth: { type: 'API_KEY', credentialName } }; + return { type: 'API_KEY', credentialName }; } - return failureResult( - new ValidationError( - `Target "${detail.name}" uses an API Key credential provider not found in this project's deployed state. ` + - 'Import the credential first with `agentcore add credential` and re-run.' - ) + throw new Error( + `Target "${detail.name}" uses an API Key credential provider not found in this project's deployed state. ` + + 'Import the credential first with `agentcore add credential` and re-run.' ); } // GATEWAY_IAM_ROLE — no outbound auth needed } - return { success: true, auth: undefined }; + return undefined; } /** * Map GetGateway + GetGatewayTarget[] responses to CLI AgentCoreGateway schema. * @internal */ -export function toGatewaySpec( - gateway: GatewayDetail, - targets: AgentCoreGatewayTarget[], - localName: string -): AgentCoreGateway { +export function toGatewaySpec(options: { + gateway: GatewayDetail; + targets: AgentCoreGatewayTarget[]; + localName: string; +}): AgentCoreGateway { + const { gateway, targets, localName } = options; const authorizerType = (gateway.authorizerType ?? 'NONE') as GatewayAuthorizerType; let authorizerConfiguration: AuthorizerConfig | undefined; @@ -287,6 +293,9 @@ export function toGatewaySpec( }; } + // Service returns protocolType 'MCP' or null. Null = non-MCP gateway. + const protocolType = gateway.protocolType === 'MCP' ? 'MCP' : 'None'; + const enableSemanticSearch = gateway.protocolConfiguration?.mcp?.searchType === 'SEMANTIC'; const exceptionLevel: GatewayExceptionLevel = gateway.exceptionLevel === 'DEBUG' ? 'DEBUG' : 'NONE'; @@ -304,6 +313,7 @@ export function toGatewaySpec( return { name: localName, resourceName: gateway.name, + ...(protocolType === 'None' && { protocolType: 'None' as const }), ...(gateway.description && { description: gateway.description }), targets, authorizerType, @@ -489,23 +499,47 @@ export async function handleImportGateway(options: ImportResourceOptions): Promi logger.startStep('Map gateway to project schema'); const credentialArnMap = await buildCredentialArnMap(ctx.configIO, targetName); + // Build reverse lookup: runtime ARN → local name (for managed target detection) + const runtimeArnToName = new Map(); + try { + const state = await ctx.configIO.readDeployedState(); + const deployedTarget = state?.targets?.[targetName]; + if (deployedTarget?.resources?.runtimes) { + for (const [name, rt] of Object.entries(deployedTarget.resources.runtimes)) { + runtimeArnToName.set(rt.runtimeArn, name); + } + } + } catch { + // No deployed state — httpRuntime targets without a matching runtime will be skipped + } + const mappedTargets: AgentCoreGatewayTarget[] = []; + const unmanagedRuntimeArns: string[] = []; for (const td of targetDetails) { - const mapped = toGatewayTargetSpec(td, credentialArnMap, onProgress); - if (!mapped.success) { - logger.endStep('error', mapped.error.message); - logger.finalize(false); - return { - ...mapped, - resourceType: 'gateway' as const, - resourceName: td.name, - logPath: logger.getRelativeLogPath(), - }; + const http = td.targetConfiguration?.http; + const runtimeConfig = http?.agentcoreRuntime ?? http?.runtimeTargetConfiguration; + if (http && runtimeConfig && !runtimeArnToName.has(runtimeConfig.runtimeArn)) { + unmanagedRuntimeArns.push(runtimeConfig.runtimeArn); + } + const mapped = toGatewayTargetSpec(td, credentialArnMap, onProgress, runtimeArnToName); + if (mapped) { + mappedTargets.push(mapped); } - if (mapped.target) mappedTargets.push(mapped.target); } - const gatewaySpec = toGatewaySpec(gatewayDetail, mappedTargets, localName); + if (unmanagedRuntimeArns.length > 0) { + const arns = [...new Set(unmanagedRuntimeArns)]; + const importCmds = arns.map(arn => ` agentcore import runtime --arn ${arn}`).join('\n'); + return failResult( + logger, + `Gateway has ${unmanagedRuntimeArns.length} httpRuntime target(s) referencing runtimes not managed by this project.\n` + + `Import the runtime(s) first:\n${importCmds}\n\nThen retry: agentcore import gateway --arn ${options.arn ?? gatewayDetail.gatewayArn}`, + 'gateway', + localName + ); + } + + const gatewaySpec = toGatewaySpec({ gateway: gatewayDetail, targets: mappedTargets, localName }); onProgress(`Mapped gateway with ${mappedTargets.length} target(s)`); if (mappedTargets.length < targetDetails.length) { onProgress( diff --git a/src/cli/commands/invoke/__tests__/action-gateway.test.ts b/src/cli/commands/invoke/__tests__/action-gateway.test.ts new file mode 100644 index 000000000..4cd4f2dab --- /dev/null +++ b/src/cli/commands/invoke/__tests__/action-gateway.test.ts @@ -0,0 +1,98 @@ +import type { InvokeContext } from '../action'; +import { handleInvoke } from '../action'; +import type { InvokeOptions } from '../types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Gateway invoke output parsing. +// +// HTTP gateways stream back the same SSE envelope as a direct runtime invoke. +// Before this fix the gateway branch returned the raw HTTP body, so users saw +// `data: "..."` frames instead of clean text. This pins the wiring: the HTTP +// gateway response is run through parseSSE (mirroring invokeAgentRuntime). The +// parsing itself is covered by agentcore.test.ts — this guards that the gateway +// branch actually calls it rather than returning the body raw. +// --------------------------------------------------------------------------- + +vi.mock('../../../feature-flags', () => ({ + isPreviewEnabled: () => false, +})); + +vi.mock('../../../logging', () => ({ + InvokeLogger: class { + logFilePath = '/tmp/fake.log'; + logPrompt = vi.fn(); + logResponse = vi.fn(); + logError = vi.fn(); + logInfo = vi.fn(); + }, +})); + +function makeContext(): InvokeContext { + const gatewayName = 'lolo-gateway'; + return { + project: { + agentCoreGateways: [ + { + name: gatewayName, + authorizerType: 'AWS_IAM', + targets: [{ name: 'lolo-target', targetType: 'httpRuntime' }], + }, + ], + runtimes: [], + } as unknown as InvokeContext['project'], + deployedState: { + targets: { + default: { + resources: { + gateways: { + [gatewayName]: { + gatewayId: 'gw-123', + gatewayUrl: 'https://gw-123.gateway.example.com', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:111122223333:gateway/gw-123', + }, + }, + }, + }, + }, + } as unknown as InvokeContext['deployedState'], + awsTargets: [{ name: 'default', region: 'us-east-1' }] as unknown as InvokeContext['awsTargets'], + }; +} + +describe('handleInvoke — gateway output parsing', () => { + beforeEach(() => { + // SigV4 signing pulls credentials from the provider chain; give it something. + process.env.AWS_ACCESS_KEY_ID = 'AKIAFAKE'; + process.env.AWS_SECRET_ACCESS_KEY = 'fakesecret'; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + }); + + it('parses multi-frame SSE into clean joined text (the lolo-gateway repro)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve('data: "Hello! How can I help you today"\n\ndata: "?"\n\n'), + }) + ); + + const options: InvokeOptions = { + gateway: 'lolo-gateway', + gatewayTarget: 'lolo-target', + targetName: 'default', + prompt: '{"message":"hello"}', + }; + const result = await handleInvoke(makeContext(), options); + + expect(result.success).toBe(true); + expect(result.response).toBe('Hello! How can I help you today?'); + }); +}); diff --git a/src/cli/commands/invoke/__tests__/build-harness-base-opts.test.ts b/src/cli/commands/invoke/__tests__/build-harness-base-opts.test.ts new file mode 100644 index 000000000..34bea7540 --- /dev/null +++ b/src/cli/commands/invoke/__tests__/build-harness-base-opts.test.ts @@ -0,0 +1,56 @@ +import { buildHarnessBaseOpts } from '../action.js'; +import type { InvokeOptions } from '../types.js'; +import { describe, expect, it } from 'vitest'; + +const base = { prompt: 'hi', targetName: 'default' } as InvokeOptions; + +describe('buildHarnessBaseOpts — model provider mapping', () => { + it('maps bedrock by default', () => { + const opts = buildHarnessBaseOpts({ ...base, modelProvider: 'bedrock', modelId: 'm' }); + expect(opts.model).toEqual({ bedrockModelConfig: { modelId: 'm' } }); + }); + + it('maps open_ai with apiKeyArn', () => { + const opts = buildHarnessBaseOpts({ ...base, modelProvider: 'open_ai', modelId: 'gpt', apiKeyArn: 'arn:key' }); + expect(opts.model).toEqual({ openAiModelConfig: { modelId: 'gpt', apiKeyArn: 'arn:key' } }); + }); + + it('maps gemini with apiKeyArn', () => { + const opts = buildHarnessBaseOpts({ ...base, modelProvider: 'gemini', modelId: 'g', apiKeyArn: 'arn:key' }); + expect(opts.model).toEqual({ geminiModelConfig: { modelId: 'g', apiKeyArn: 'arn:key' } }); + }); + + it('maps lite_llm with apiBase and additionalParams (apiKeyArn optional)', () => { + const opts = buildHarnessBaseOpts({ + ...base, + modelProvider: 'lite_llm', + modelId: 'anthropic/claude-sonnet-4-5', + apiBase: 'https://proxy.example.com/v1', + additionalParams: { reasoning_effort: 'high' }, + }); + expect(opts.model).toEqual({ + liteLlmModelConfig: { + modelId: 'anthropic/claude-sonnet-4-5', + apiBase: 'https://proxy.example.com/v1', + additionalParams: { reasoning_effort: 'high' }, + }, + }); + }); + + it('maps a keyless lite_llm override from only apiBase', () => { + const opts = buildHarnessBaseOpts({ + ...base, + modelProvider: 'lite_llm', + modelId: 'ollama/llama3', + apiBase: 'http://localhost:11434', + }); + expect(opts.model).toEqual({ + liteLlmModelConfig: { modelId: 'ollama/llama3', apiBase: 'http://localhost:11434' }, + }); + }); + + it('falls back to the harness spec provider when no override is given', () => { + const opts = buildHarnessBaseOpts({ ...base, apiBase: 'https://proxy' }, { provider: 'lite_llm', modelId: 'm' }); + expect(opts.model).toEqual({ liteLlmModelConfig: { modelId: 'm', apiBase: 'https://proxy' } }); + }); +}); diff --git a/src/cli/commands/invoke/__tests__/command.test.ts b/src/cli/commands/invoke/__tests__/command.test.ts index 5da2cd9ba..cf6c8d7ed 100644 --- a/src/cli/commands/invoke/__tests__/command.test.ts +++ b/src/cli/commands/invoke/__tests__/command.test.ts @@ -1,4 +1,5 @@ // Tests for invoke CLI mode — exitCode propagation and flag validation +import { isPreviewEnabled } from '../../../feature-flags'; import { handleInvoke } from '../action.js'; import { registerInvoke } from '../command.js'; import { resolvePrompt } from '../resolve-prompt.js'; @@ -127,4 +128,28 @@ describe('invoke CLI mode — exitCode propagation', () => { expect(exitCodes[0]).toBe(1); }); + + it('emits a JSON error envelope (not console.error) for bad --additional-params in --json mode', async () => { + // --additional-params is preview-gated; enable preview for this case. + vi.mocked(isPreviewEnabled).mockReturnValue(true); + const logged: string[] = []; + const erred: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => logged.push(args.join(' '))); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => erred.push(args.join(' '))); + + const program = new Command(); + program.exitOverride(); + registerInvoke(program); + + await program + .parseAsync(['invoke', '--harness', 'h', '--additional-params', '{not json}', '--json', 'hi'], { from: 'user' }) + .catch(_noop); + + expect(exitCodes[0]).toBe(1); + const out = logged.join(''); + expect(out).toContain('"success":false'); + expect(out).toContain('--additional-params must be a valid JSON object'); + // In --json mode the error must NOT go to console.error (would break JSON consumers). + expect(erred.join('')).not.toContain('--additional-params'); + }); }); diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index 9c8584ca1..5b867eac5 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -4,6 +4,7 @@ import { DEFAULT_RUNTIME_USER_ID, buildAguiRunInput, executeBashCommand, + extractResult, getOrCreatePaymentSession, invokeA2ARuntime, invokeAgentRuntime, @@ -12,8 +13,10 @@ import { mcpCallTool, mcpInitSession, mcpListTools, + parseSSE, } from '../../aws'; import { invokeHarness } from '../../aws/agentcore-harness'; +import { dnsSuffix } from '../../aws/partition'; import { ANSI } from '../../constants'; import { isPreviewEnabled } from '../../feature-flags'; import { InvokeLogger } from '../../logging'; @@ -46,6 +49,196 @@ export async function loadInvokeConfig(configIO: ConfigIO = new ConfigIO()): Pro export async function handleInvoke(context: InvokeContext, options: InvokeOptions = {}): Promise { const { project, deployedState, awsTargets } = context; + // Gateway invoke: route through a deployed gateway + if (options.gateway) { + const targetNames = Object.keys(deployedState.targets); + if (targetNames.length === 0) { + return { + success: false, + error: new ResourceNotFoundError('No deployed targets found. Run `agentcore deploy` first.'), + }; + } + const gwSelectedTarget = options.targetName ?? targetNames[0]!; + const gwTargetState = deployedState.targets[gwSelectedTarget]; + const gwTargetConfig = awsTargets.find(t => t.name === gwSelectedTarget); + if (!gwTargetConfig) { + return { + success: false, + error: new ResourceNotFoundError(`Target config '${gwSelectedTarget}' not found in aws-targets`), + }; + } + const gwState = + gwTargetState?.resources?.gateways?.[options.gateway] ?? + gwTargetState?.resources?.mcp?.gateways?.[options.gateway]; + if (!gwState) { + return { + success: false, + error: new ResourceNotFoundError( + `Gateway '${options.gateway}' is not deployed. Run \`agentcore deploy\` first.` + ), + }; + } + + const gwSpec = project.agentCoreGateways?.find(g => g.name === options.gateway); + const isMcpGateway = gwSpec?.protocolType === 'MCP'; + + // Require bearer token for CUSTOM_JWT gateways + if (gwSpec?.authorizerType === 'CUSTOM_JWT' && !options.bearerToken) { + return { + success: false, + error: new ValidationError('Gateway requires --bearer-token (CUSTOM_JWT authorizer).'), + }; + } + + const region = gwTargetConfig.region; + const gatewayUrl = + gwState.gatewayUrl ?? + (() => { + const r = gwState.gatewayArn?.split(':')[3]; + return r ? `https://${gwState.gatewayId}.gateway.bedrock-agentcore.${r}.${dnsSuffix(r)}` : undefined; + })(); + + if (!gatewayUrl) { + return { success: false, error: new ValidationError('Could not determine gateway URL.') }; + } + + let invocationUrl: string; + let body: string; + + if (!isMcpGateway) { + // HTTP gateway: requires --target-name and --prompt + if (!options.gatewayTarget) { + const httpTargets = (gwSpec?.targets ?? []) + .filter((t: { targetType?: string }) => t.targetType === 'httpRuntime') + .map((t: { name: string }) => t.name); + return { + success: false, + error: new ValidationError( + `--target-name is required for HTTP gateways. Available targets: ${httpTargets.join(', ')}` + ), + }; + } + const targetSpec = gwSpec?.targets?.find( + (t: { name: string; targetType?: string }) => t.name === options.gatewayTarget + ); + if (!targetSpec) { + return { + success: false, + error: new ResourceNotFoundError( + `Target '${options.gatewayTarget}' not found on gateway '${options.gateway}'.` + ), + }; + } + if (targetSpec.targetType !== 'httpRuntime') { + return { + success: false, + error: new ValidationError(`Target '${options.gatewayTarget}' is not an httpRuntime target.`), + }; + } + invocationUrl = `${gatewayUrl}/${options.gatewayTarget}/invocations`; + if (!options.prompt) { + return { success: false, error: new ValidationError('--prompt is required for HTTP gateway invoke.') }; + } + // Wrap plain strings into {"prompt": "..."} so the body field matches both the + // agent template (reads payload.get("prompt")) and the guardrail form default + // data path (context.input.prompt). If the prompt is already valid JSON, pass + // it through as-is. + try { + JSON.parse(options.prompt); + body = options.prompt; + } catch { + body = JSON.stringify({ prompt: options.prompt }); + } + } else { + // MCP gateway: direct HTTP POST to gateway /mcp endpoint + if (!options.tool) { + return { + success: false, + error: new ValidationError('--tool is required for MCP gateway invoke.'), + }; + } + + invocationUrl = gatewayUrl.endsWith('/mcp') ? gatewayUrl : `${gatewayUrl}/mcp`; + body = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: options.tool, + arguments: options.input ? (JSON.parse(options.input) as Record) : {}, + }, + }); + } + + const headers: Record = { + 'content-type': 'application/json', + ...(options.sessionId && { 'x-amz-bedrock-agentcore-session-id': options.sessionId }), + ...(isMcpGateway && { Accept: 'application/json, text/event-stream', 'Mcp-Protocol-Version': '2025-03-26' }), + }; + + let fetchHeaders = headers; + + if (gwSpec?.authorizerType === 'CUSTOM_JWT') { + // CUSTOM_JWT: use bearer token + fetchHeaders = { ...headers, authorization: `Bearer ${options.bearerToken}` }; + } else if (gwSpec?.authorizerType === 'AWS_IAM') { + // AWS_IAM: SigV4 sign the request + const { SignatureV4 } = await import('@smithy/signature-v4'); + const { Sha256 } = await import('@aws-crypto/sha256-js'); + const { fromNodeProviderChain } = await import('@aws-sdk/credential-providers'); + + const url = new URL(invocationUrl); + const signer = new SignatureV4({ + service: 'bedrock-agentcore', + region, + credentials: fromNodeProviderChain(), + sha256: Sha256, + }); + + const signed = await signer.sign({ + method: 'POST', + protocol: 'https:', + hostname: url.hostname, + path: url.pathname, + headers: { ...headers, host: url.hostname }, + body, + }); + + fetchHeaders = signed.headers as Record; + } + // NONE: no auth needed, use headers as-is + + const response = await fetch(invocationUrl, { + method: 'POST', + headers: fetchHeaders, + body, + }); + + if (!response.ok) { + const errText = await response.text(); + return { success: false, error: new Error(`Gateway invoke failed (${response.status}): ${errText}`) }; + } + + const responseText = await response.text(); + + // HTTP gateways stream back the same SSE / JSON envelope as a direct runtime + // invoke, so parse it the same way (mirrors invokeAgentRuntime) to render clean + // text instead of raw `data: "..."` frames. MCP gateways return JSON-RPC, which + // the caller renders as-is. + const responseBody = isMcpGateway + ? responseText + : responseText.includes('data: ') + ? parseSSE(responseText) + : extractResult(responseText); + + return { + success: true, + response: responseBody, + agentName: options.gatewayTarget ?? options.gateway, + targetName: gwSelectedTarget, + }; + } + // Preview: route to harness before runtime resolution if (isPreviewEnabled()) { const harnessEntries = project.harnesses ?? []; @@ -68,15 +261,15 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption ), }; } - const targetState = deployedState.targets[selectedTarget]; - const targetConfig = awsTargets.find(t => t.name === selectedTarget); - if (!targetConfig) { + const harnessTargetState = deployedState.targets[selectedTarget]; + const harnessTargetConfig = awsTargets.find(t => t.name === selectedTarget); + if (!harnessTargetConfig) { return { success: false, error: new ResourceNotFoundError(`Target config '${selectedTarget}' not found in aws-targets`), }; } - return handleHarnessInvoke(project, targetState, targetConfig, selectedTarget, options); + return handleHarnessInvoke(project, harnessTargetState, harnessTargetConfig, selectedTarget, options); } if (harnessEntries.length > 0 && project.runtimes.length > 0 && !options.agentName) { @@ -587,10 +780,12 @@ export function buildHarnessBaseOpts( harnessSpec?: Partial ): Partial { const baseOpts: Partial = {}; - if (options.modelId || options.modelProvider || options.apiKeyArn) { + if (options.modelId || options.modelProvider || options.apiKeyArn || options.apiBase || options.additionalParams) { const provider = options.modelProvider ?? harnessSpec?.provider; const modelId = options.modelId ?? harnessSpec?.modelId ?? ''; const apiKeyArn = options.apiKeyArn ?? harnessSpec?.apiKeyArn; + const apiBase = options.apiBase ?? harnessSpec?.apiBase; + const additionalParams = options.additionalParams ?? harnessSpec?.additionalParams; switch (provider) { case 'open_ai': baseOpts.model = { @@ -602,6 +797,16 @@ export function buildHarnessBaseOpts( geminiModelConfig: { modelId, ...(apiKeyArn && { apiKeyArn }) }, }; break; + case 'lite_llm': + baseOpts.model = { + liteLlmModelConfig: { + modelId, + ...(apiKeyArn && { apiKeyArn }), + ...(apiBase && { apiBase }), + ...(additionalParams && { additionalParams }), + }, + }; + break; default: baseOpts.model = { bedrockModelConfig: { modelId }, @@ -618,7 +823,14 @@ export function buildHarnessBaseOpts( if (options.maxIterations != null) baseOpts.maxIterations = options.maxIterations; if (options.maxTokens != null) baseOpts.maxTokens = options.maxTokens; if (options.harnessTimeout != null) baseOpts.timeoutSeconds = options.harnessTimeout; - if (options.skills) baseOpts.skills = options.skills.split(',').map(p => ({ path: p.trim() })); + if (options.skills) { + baseOpts.skills = options.skills.split(',').map(s => { + const trimmed = s.trim(); + if (trimmed.startsWith('s3://')) return { s3Uri: trimmed }; + if (trimmed.startsWith('https://')) return { gitUrl: trimmed }; + return { path: trimmed }; + }); + } if (options.systemPrompt) baseOpts.systemPrompt = [{ text: options.systemPrompt }]; if (options.allowedTools) baseOpts.allowedTools = options.allowedTools.split(',').map(t => t.trim()); if (options.actorId) baseOpts.actorId = options.actorId; diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index ae98d7159..42c9f697f 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -2,6 +2,7 @@ import { ValidationError, serializeResult } from '../../../lib'; import { COMMAND_DESCRIPTIONS } from '../../constants'; import { getErrorMessage } from '../../errors'; import { isPreviewEnabled } from '../../feature-flags'; +import { ADDITIONAL_PARAMS_JSON_ERROR } from '../../primitives/constants'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { renderTUI } from '../../tui'; import { requireProject, requireTTY } from '../../tui/guards'; @@ -139,6 +140,8 @@ export const registerInvoke = (program: Command) => { 'Read the prompt from a file (for long or structured payloads that exceed shell arg limits) [non-interactive]' ) .option('--runtime ', 'Select specific runtime [non-interactive]') + .option('--gateway ', 'Invoke through a gateway [non-interactive]') + .option('--gateway-target-name ', 'HTTP runtime target on the gateway [non-interactive]') .option('--target ', 'Select deployment target [non-interactive]') .option('--session-id ', 'Use specific session ID for conversation continuity') .option('--user-id ', 'User ID for runtime invocation (default: "default-user")') @@ -168,37 +171,32 @@ export const registerInvoke = (program: Command) => { if (isPreviewEnabled()) { invokeCmd - .option('--harness ', 'Select specific harness to invoke [non-interactive] [preview]') - .option('--harness-arn ', 'Invoke a harness by ARN (no project required) [non-interactive] [preview]') - .option( - '--region ', - 'AWS region (required with --harness-arn when no project) [non-interactive] [preview]' - ) - .option('--verbose', 'Print verbose streaming JSON events (harness only) [non-interactive] [preview]') - .option('--model-id ', 'Override model for this invocation (harness only) [non-interactive] [preview]') + .option('--harness ', 'Select specific harness to invoke [non-interactive]') + .option('--harness-arn ', 'Invoke a harness by ARN (no project required) [non-interactive]') + .option('--region ', 'AWS region (required with --harness-arn when no project) [non-interactive]') + .option('--verbose', 'Print verbose streaming JSON events (harness only) [non-interactive]') + .option('--model-id ', 'Override model for this invocation (harness only) [non-interactive]') .option( '--model-provider ', - 'Override model provider: bedrock, open_ai, gemini (harness only) [non-interactive] [preview]' - ) - .option( - '--api-key-arn ', - 'Override API key ARN for open_ai/gemini (harness only) [non-interactive] [preview]' + 'Override model provider: bedrock, open_ai, gemini, lite_llm (harness only) [non-interactive]' ) - .option('--tools ', 'Override tools, comma-separated (harness only) [non-interactive] [preview]') - .option('--max-iterations ', 'Override max iterations (harness only) [non-interactive] [preview]', parseInt) - .option('--max-tokens ', 'Override max tokens (harness only) [non-interactive] [preview]', parseInt) + .option('--api-key-arn ', 'Override API key ARN for open_ai/gemini (harness only) [non-interactive]') + .option('--api-base ', 'Override LiteLLM API base URL (harness only, lite_llm) [non-interactive]') .option( - '--harness-timeout ', - 'Override timeout seconds (harness only) [non-interactive] [preview]', - parseInt + '--additional-params ', + 'Override LiteLLM additional params as a JSON object (harness only, lite_llm) [non-interactive]' ) - .option('--skills ', 'Skills to use, comma-separated paths (harness only) [non-interactive] [preview]') - .option('--system-prompt ', 'Override system prompt (harness only) [non-interactive] [preview]') + .option('--tools ', 'Override tools, comma-separated (harness only) [non-interactive]') + .option('--max-iterations ', 'Override max iterations (harness only) [non-interactive]', parseInt) + .option('--max-tokens ', 'Override max tokens (harness only) [non-interactive]', parseInt) + .option('--harness-timeout ', 'Override timeout seconds (harness only) [non-interactive]', parseInt) .option( - '--allowed-tools ', - 'Override allowed tools, comma-separated (harness only) [non-interactive] [preview]' + '--skills ', + 'Skills override, comma-separated (path, s3://uri, or https://git-url). Git auth not supported here — configure via agentcore add skill [non-interactive]' ) - .option('--actor-id ', 'Override memory actor ID (harness only) [non-interactive] [preview]'); + .option('--system-prompt ', 'Override system prompt (harness only) [non-interactive]') + .option('--allowed-tools ', 'Override allowed tools, comma-separated (harness only) [non-interactive]') + .option('--actor-id ', 'Override memory actor ID (harness only) [non-interactive]'); } // Group the long flag list into labelled sections (mirrors `add ab-test`). @@ -271,13 +269,13 @@ MCP & Advanced [non-interactive] invokeCmd.addHelpText( 'after', ` -Harness [non-interactive] [preview] +Harness [non-interactive] --harness Select specific harness to invoke --harness-arn Invoke a harness by ARN (no project required) --region AWS region (required with --harness-arn) --verbose Print verbose streaming JSON events -Model & Runtime Overrides (harness only) [non-interactive] [preview] +Model & Runtime Overrides (harness only) [non-interactive] --model-id Override model --model-provider bedrock, open_ai, or gemini --api-key-arn API key ARN for open_ai/gemini @@ -300,6 +298,8 @@ Model & Runtime Overrides (harness only) [non-interactive] [preview] prompt?: string; promptFile?: string; runtime?: string; + gateway?: string; + gatewayTargetName?: string; target?: string; sessionId?: string; userId?: string; @@ -318,6 +318,8 @@ Model & Runtime Overrides (harness only) [non-interactive] [preview] modelId?: string; modelProvider?: string; apiKeyArn?: string; + apiBase?: string; + additionalParams?: string; tools?: string; maxIterations?: number; maxTokens?: number; @@ -366,8 +368,10 @@ Model & Runtime Overrides (harness only) [non-interactive] [preview] resolved.prompt !== undefined || cliOptions.json || cliOptions.target || + cliOptions.gatewayTargetName || cliOptions.stream || cliOptions.runtime || + cliOptions.gateway || cliOptions.tool || cliOptions.exec || cliOptions.bearerToken || @@ -404,9 +408,25 @@ Model & Runtime Overrides (harness only) [non-interactive] [preview] headers = parseHeaderFlags(cliOptions.header); } + let additionalParams: Record | undefined; + if (cliOptions.additionalParams) { + try { + additionalParams = JSON.parse(cliOptions.additionalParams) as Record; + } catch { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: ADDITIONAL_PARAMS_JSON_ERROR })); + } else { + console.error(ADDITIONAL_PARAMS_JSON_ERROR); + } + process.exit(1); + } + } + const options: InvokeOptions = { prompt: resolved.prompt, agentName: cliOptions.runtime, + gateway: cliOptions.gateway, + gatewayTarget: cliOptions.gatewayTargetName, targetName: cliOptions.target ?? 'default', sessionId: cliOptions.sessionId, userId: cliOptions.userId, @@ -425,6 +445,8 @@ Model & Runtime Overrides (harness only) [non-interactive] [preview] modelId: cliOptions.modelId, modelProvider: cliOptions.modelProvider, apiKeyArn: cliOptions.apiKeyArn, + apiBase: cliOptions.apiBase, + additionalParams, tools: cliOptions.tools, maxIterations: cliOptions.maxIterations, maxTokens: cliOptions.maxTokens, diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts index d06364bfb..66367c019 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -5,6 +5,10 @@ export interface InvokeOptions { harnessName?: string; /** Direct harness ARN — bypasses project config and deployed state resolution */ harnessArn?: string; + /** Gateway name — invoke through a deployed gateway */ + gateway?: string; + /** Gateway target name (httpRuntime target on the gateway) */ + gatewayTarget?: string; /** AWS region (used with --harness-arn) */ region?: string; targetName?: string; @@ -31,10 +35,14 @@ export interface InvokeOptions { verbose?: boolean; /** Override model ID for this invocation (harness only) */ modelId?: string; - /** Override model provider for this invocation (harness only): bedrock, open_ai, gemini */ + /** Override model provider for this invocation (harness only): bedrock, open_ai, gemini, lite_llm */ modelProvider?: string; - /** Override API key ARN for this invocation (harness only, open_ai/gemini) */ + /** Override API key ARN for this invocation (harness only, open_ai/gemini; optional for lite_llm) */ apiKeyArn?: string; + /** Override LiteLLM API base URL for this invocation (harness only, lite_llm) */ + apiBase?: string; + /** Override LiteLLM additional params for this invocation (harness only, lite_llm; JSON object) */ + additionalParams?: Record; /** Override tools for this invocation (harness only, comma-separated) */ tools?: string; /** Override max iterations (harness only) */ diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 06de367e1..d6f58d858 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -55,6 +55,7 @@ describe('resolveAgentContext', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -62,7 +63,6 @@ describe('resolveAgentContext', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -122,6 +122,7 @@ describe('resolveAgentContext', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -129,7 +130,6 @@ describe('resolveAgentContext', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -169,6 +169,7 @@ describe('resolveAgentContext', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -176,7 +177,6 @@ describe('resolveAgentContext', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -226,6 +226,7 @@ describe('resolveAgentContext', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -233,7 +234,6 @@ describe('resolveAgentContext', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], diff --git a/src/cli/commands/pause/__tests__/promote.test.ts b/src/cli/commands/pause/__tests__/promote.test.ts deleted file mode 100644 index 4b1ae200b..000000000 --- a/src/cli/commands/pause/__tests__/promote.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { waitForRunningThenStop } from '../promote-utils.js'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockGetABTest = vi.fn(); -const mockUpdateABTest = vi.fn(); - -vi.mock('../../../aws/agentcore-ab-tests', () => ({ - getABTest: (...args: unknown[]) => mockGetABTest(...args), - updateABTest: (...args: unknown[]) => mockUpdateABTest(...args), -})); - -describe('waitForRunningThenStop', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUpdateABTest.mockResolvedValue({ executionStatus: 'STOPPED' }); - }); - - it('stops immediately when already RUNNING', async () => { - mockGetABTest.mockResolvedValue({ executionStatus: 'RUNNING' }); - - await waitForRunningThenStop('us-east-1', 'abt-123', 'MyTest', 3, 0); - - expect(mockGetABTest).toHaveBeenCalledTimes(1); - expect(mockUpdateABTest).toHaveBeenCalledWith({ - region: 'us-east-1', - abTestId: 'abt-123', - executionStatus: 'STOPPED', - }); - }); - - it('polls until RUNNING then stops', async () => { - mockGetABTest - .mockResolvedValueOnce({ executionStatus: 'UPDATING' }) - .mockResolvedValueOnce({ executionStatus: 'UPDATING' }) - .mockResolvedValueOnce({ executionStatus: 'RUNNING' }); - - await waitForRunningThenStop('us-east-1', 'abt-123', 'MyTest', 5, 0); - - expect(mockGetABTest).toHaveBeenCalledTimes(3); - expect(mockUpdateABTest).toHaveBeenCalledOnce(); - }); - - it('throws if AB test never reaches RUNNING', async () => { - mockGetABTest.mockResolvedValue({ executionStatus: 'UPDATING' }); - - await expect(waitForRunningThenStop('us-east-1', 'abt-123', 'MyTest', 3, 0)).rejects.toThrow( - 'did not reach RUNNING state' - ); - - expect(mockGetABTest).toHaveBeenCalledTimes(3); - expect(mockUpdateABTest).not.toHaveBeenCalled(); - }); - - it('includes current status in the error message', async () => { - mockGetABTest.mockResolvedValue({ executionStatus: 'STOPPED' }); - - await expect(waitForRunningThenStop('us-east-1', 'abt-123', 'MyTest', 2, 0)).rejects.toThrow('current: STOPPED'); - }); -}); diff --git a/src/cli/commands/pause/command.tsx b/src/cli/commands/pause/command.tsx index 5ef404ed7..d790fe191 100644 --- a/src/cli/commands/pause/command.tsx +++ b/src/cli/commands/pause/command.tsx @@ -1,13 +1,11 @@ import { ConfigIO, serializeResult } from '../../../lib'; -import { listABTests, updateABTest } from '../../aws/agentcore-ab-tests'; -import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; -import { COMMAND_DESCRIPTIONS } from '../../constants'; import { getErrorMessage } from '../../errors'; import { handlePauseResume } from '../../operations/eval'; import type { OnlineEvalActionOptions } from '../../operations/eval'; +import { createJobEngine } from '../../operations/jobs'; +import { runCliCommand } from '../../telemetry/cli-command-run'; +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; -import { getRegion } from '../shared/region-utils'; -import { waitForRunningThenStop } from './promote-utils'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; import React from 'react'; @@ -72,176 +70,86 @@ function registerOnlineEvalSubcommand(parent: Command, action: 'pause' | 'resume }); } -async function resolveABTestId( - testName: string, - region: string -): Promise<{ abTestId: string; region: string; error?: string }> { - let projectName: string | undefined; - try { - const configIO = new ConfigIO(); - const deployedState = await configIO.readDeployedState(); - const awsTargets = await configIO.readAWSDeploymentTargets(); - - try { - const projectSpec = await configIO.readProjectSpec(); - projectName = projectSpec.name; - } catch { - // Project spec unavailable - } - - for (const [targetName, target] of Object.entries(deployedState.targets ?? {})) { - const abTests = target.resources?.abTests; - if (abTests?.[testName]) { - const targetConfig = awsTargets.find(t => t.name === targetName); - const resolvedRegion = targetConfig?.region ?? region; - return { abTestId: abTests[testName].abTestId, region: resolvedRegion }; - } - } - } catch { - // No deployed state - } - - try { - const result = await listABTests({ region, maxResults: 100 }); - // Match against both prefixed name ({projectName}_{testName}) and bare testName (backwards compat) - const prefixedName = projectName ? `${projectName}_${testName}` : undefined; - const match = - result.abTests.find(t => prefixedName != null && t.name === prefixedName) ?? - result.abTests.find(t => t.name === testName); - if (match) return { abTestId: match.abTestId, region }; - } catch { - // API call failed - } - - return { abTestId: '', region, error: `AB test "${testName}" not found in deployed state or API.` }; -} - function registerABTestSubcommand(parent: Command, action: 'pause' | 'resume') { - const executionStatus = action === 'pause' ? 'PAUSED' : 'RUNNING'; - const pastTense = action === 'pause' ? 'Paused' : 'Resumed'; + const verb = action === 'pause' ? 'Pause' : 'Resume'; parent .command('ab-test') - .description(`[preview] ${action === 'pause' ? 'Pause' : 'Resume'} a deployed A/B test`) - .argument('', 'AB test name') - .option('--region ', 'AWS region') + .description(`${verb} a running A/B test`) + .requiredOption('-i, --id ', 'A/B test ID') + .option('--region ', 'AWS region (auto-detected if omitted)') .option('--json', 'Output as JSON') - .action(async (name: string, cliOptions: { region?: string; json?: boolean }) => { - try { - const region = await getRegion(cliOptions.region); - const { abTestId, error } = await resolveABTestId(name, region); - if (error) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error })); - } else { - console.error(error); - } - process.exit(1); - } - - const result = await updateABTest({ - region, - abTestId, - executionStatus, - }); - - if (cliOptions.json) { - console.log(JSON.stringify({ success: true, ...result })); - } else { - console.log(`${pastTense} AB test "${name}" (execution: ${result.executionStatus})`); + .action((cliOptions: { id: string; region?: string; json?: boolean }) => { + requireProject(); + + return runCliCommand(action === 'pause' ? 'pause.job' : 'resume.job', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + const result = + action === 'pause' + ? await engine.pause('ab-test', cliOptions.id) + : await engine.resume('ab-test', cliOptions.id); + if (!result.success) { + throw result.error; } - process.exit(0); - } catch (error) { if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + console.log(JSON.stringify({ success: true, id: cliOptions.id })); } else { - console.error(`Error: ${getErrorMessage(error)}`); + console.log(`\n✓ A/B test ${cliOptions.id} ${action === 'pause' ? 'paused' : 'resumed'}.\n`); } - process.exit(1); - } + return { job_type: 'ab-test' }; + }); }); } -export const registerPause = (program: Command) => { - const pauseCmd = program.command('pause').description(COMMAND_DESCRIPTIONS.pause); - registerOnlineEvalSubcommand(pauseCmd, 'pause'); - registerABTestSubcommand(pauseCmd, 'pause'); -}; - -export const registerResume = (program: Command) => { - const resumeCmd = program.command('resume').description(COMMAND_DESCRIPTIONS.resume); - registerOnlineEvalSubcommand(resumeCmd, 'resume'); - registerABTestSubcommand(resumeCmd, 'resume'); -}; - -export const registerStop = (program: Command) => { - const stopCmd = program.command('stop').description('Stop resources'); +function registerOnlineInsightsSubcommand(parent: Command, action: 'pause' | 'resume') { + const description = + action === 'pause' + ? '[preview] Pause a deployed online insights config. Use --arn to target configs outside the project.' + : '[preview] Resume a paused online insights config. Use --arn to target configs outside the project.'; + const pastTense = action === 'pause' ? 'Paused' : 'Resumed'; - stopCmd - .command('ab-test') - .description('[preview] Stop a deployed A/B test permanently') - .argument('', 'AB test name') - .option('--region ', 'AWS region') + parent + .command('online-insights') + .description(description) + .argument('[name]', 'Config name from project (not needed with --arn)') + .option('--arn ', 'Online insights config ARN — operate without a project directory') + .option('--region ', 'AWS region override (auto-detected from ARN otherwise)') .option('--json', 'Output as JSON') - .action(async (name: string, cliOptions: { region?: string; json?: boolean }) => { - try { - const region = await getRegion(cliOptions.region); - const { abTestId, error } = await resolveABTestId(name, region); - if (error) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error })); - } else { - console.error(error); - } - process.exit(1); - } - - const result = await updateABTest({ - region, - abTestId, - executionStatus: 'STOPPED', - }); - - if (cliOptions.json) { - console.log(JSON.stringify({ success: true, ...result })); - } else { - console.log(`Stopped AB test "${name}" (execution: ${result.executionStatus})`); - } - process.exit(0); - } catch (error) { + .action(async (name: string | undefined, cliOptions: { arn?: string; region?: string; json?: boolean }) => { + if (!cliOptions.arn && !name) { + const error = 'Either a config name or --arn is required'; if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + console.log(JSON.stringify({ success: false, error })); } else { - console.error(`Error: ${getErrorMessage(error)}`); + render({error}); } process.exit(1); } - }); - stopCmd - .command('batch-evaluation') - .description('[preview] Stop a running batch evaluation') - .requiredOption('-i, --id ', 'Batch evaluation ID to stop') - .option('--region ', 'AWS region (auto-detected if omitted)') - .option('--json', 'Output as JSON') - .action(async (cliOptions: { id: string; region?: string; json?: boolean }) => { - try { - const region = await getRegion(cliOptions.region); + if (!cliOptions.arn) { + requireProject(); + } + + const options: OnlineEvalActionOptions = { + name: name ?? '', + arn: cliOptions.arn, + region: cliOptions.region, + json: cliOptions.json, + }; - const result = await stopBatchEvaluation({ - region, - batchEvaluationId: cliOptions.id, - }); + try { + const result = await handlePauseResume(options, action); if (cliOptions.json) { - console.log(JSON.stringify({ success: true, ...result })); + console.log(JSON.stringify(serializeResult(result))); + } else if (result.success) { + const displayName = cliOptions.arn ? result.configId : name; + console.log(`${pastTense} online insights config "${displayName}" (status: ${result.executionStatus})`); } else { - console.log(`\nBatch evaluation stopped successfully`); - console.log(`ID: ${result.batchEvaluationId}`); - console.log(`Status: ${result.status}\n`); + render({result.error.message}); } - process.exit(0); + process.exit(result.success ? 0 : 1); } catch (error) { if (cliOptions.json) { console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); @@ -251,71 +159,18 @@ export const registerStop = (program: Command) => { process.exit(1); } }); -}; - -export const registerPromote = (program: Command) => { - const promoteCmd = program.command('promote').description('Promote resources'); - - promoteCmd - .command('ab-test') - .description('Promote the winning treatment of an A/B test') - .argument('', 'AB test name') - .option('--region ', 'AWS region') - .option('--json', 'Output as JSON') - .action(async (name: string, cliOptions: { region?: string; json?: boolean }) => { - try { - const region = await getRegion(cliOptions.region); - const { abTestId, error } = await resolveABTestId(name, region); - if (error) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error })); - } else { - console.error(error); - } - process.exit(1); - } - - const result = await waitForRunningThenStop(region, abTestId, name); +} - // Apply promotion to agentcore.json - const { promoteABTestConfig } = await import('../../operations/ab-test/promote'); - let promoted = false; - let mode: string | undefined; - let promotionDetail = ''; - try { - const promoResult = await promoteABTestConfig(abTestId, name); - promoted = promoResult.promoted; - mode = promoResult.mode; - promotionDetail = promoResult.promotionDetail; - } catch { - // Config read/write failed - } +export const registerPause = (program: Command) => { + const pauseCmd = program.command('pause').description(COMMAND_DESCRIPTIONS.pause); + registerOnlineEvalSubcommand(pauseCmd, 'pause'); + registerOnlineInsightsSubcommand(pauseCmd, 'pause'); + registerABTestSubcommand(pauseCmd, 'pause'); +}; - if (cliOptions.json) { - console.log( - JSON.stringify({ - success: true, - ...result, - ...(mode && { mode }), - promoted, - ...(promotionDetail && { promotionDetail }), - }) - ); - } else { - console.log(`AB test "${name}" stopped.`); - if (promoted) { - console.log(`\n${promotionDetail}`); - console.log(`\nRun: agentcore deploy`); - } - } - process.exit(0); - } catch (error) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); - } else { - console.error(`Error: ${getErrorMessage(error)}`); - } - process.exit(1); - } - }); +export const registerResume = (program: Command) => { + const resumeCmd = program.command('resume').description(COMMAND_DESCRIPTIONS.resume); + registerOnlineEvalSubcommand(resumeCmd, 'resume'); + registerOnlineInsightsSubcommand(resumeCmd, 'resume'); + registerABTestSubcommand(resumeCmd, 'resume'); }; diff --git a/src/cli/commands/pause/index.ts b/src/cli/commands/pause/index.ts index 1bc38e3be..858054fd2 100644 --- a/src/cli/commands/pause/index.ts +++ b/src/cli/commands/pause/index.ts @@ -1 +1 @@ -export { registerPause, registerPromote } from './command'; +export { registerPause } from './command'; diff --git a/src/cli/commands/pause/promote-utils.ts b/src/cli/commands/pause/promote-utils.ts deleted file mode 100644 index 9bca03f8a..000000000 --- a/src/cli/commands/pause/promote-utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getABTest, updateABTest } from '../../aws/agentcore-ab-tests'; -import type { UpdateABTestResult } from '../../aws/agentcore-ab-tests'; - -/** - * Poll until the AB test reaches RUNNING status, then stop it. - * Throws if the test never reaches RUNNING within the allotted attempts. - */ -export async function waitForRunningThenStop( - region: string, - abTestId: string, - name: string, - maxAttempts = 12, - delayMs = 10_000 -): Promise { - let currentStatus: string | undefined; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const current = await getABTest({ region, abTestId }); - currentStatus = current.executionStatus; - if (currentStatus === 'RUNNING') break; - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - if (currentStatus !== 'RUNNING') { - throw new Error( - `AB test "${name}" did not reach RUNNING state after waiting (current: ${currentStatus}). Cannot promote.` - ); - } - return updateABTest({ region, abTestId, executionStatus: 'STOPPED' }); -} diff --git a/src/cli/commands/promote/command.tsx b/src/cli/commands/promote/command.tsx new file mode 100644 index 000000000..d7ff6dcf4 --- /dev/null +++ b/src/cli/commands/promote/command.tsx @@ -0,0 +1,34 @@ +import { ConfigIO } from '../../../lib'; +import { createJobEngine } from '../../operations/jobs'; +import { runCliCommand } from '../../telemetry/cli-command-run'; +import { requireProject } from '../../tui/guards'; +import type { Command } from '@commander-js/extra-typings'; + +export const registerPromote = (program: Command) => { + const promoteCmd = program.command('promote').description('Promote resources'); + + promoteCmd + .command('ab-test') + .description('Promote the winning treatment of an A/B test (stops the test and updates agentcore.json)') + .requiredOption('-i, --id ', 'A/B test ID') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--json', 'Output as JSON') + .action((cliOptions: { id: string; region?: string; json?: boolean }) => { + requireProject(); + + return runCliCommand('promote.job', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + const result = await engine.promote('ab-test', cliOptions.id); + if (!result.success) { + throw result.error; + } + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, id: cliOptions.id })); + } else { + console.log(`\n✓ A/B test ${cliOptions.id} stopped and winning variant applied to agentcore.json.`); + console.log(`\nRun: agentcore deploy\n`); + } + return { job_type: 'ab-test' }; + }); + }); +}; diff --git a/src/cli/commands/promote/index.ts b/src/cli/commands/promote/index.ts new file mode 100644 index 000000000..e167015a9 --- /dev/null +++ b/src/cli/commands/promote/index.ts @@ -0,0 +1 @@ +export { registerPromote } from './command'; diff --git a/src/cli/commands/recommendations/command.tsx b/src/cli/commands/recommendations/command.tsx index cccbbc8f6..fec97617d 100644 --- a/src/cli/commands/recommendations/command.tsx +++ b/src/cli/commands/recommendations/command.tsx @@ -1,63 +1,57 @@ -import { COMMAND_DESCRIPTIONS } from '../../constants'; -import { getErrorMessage } from '../../errors'; -import { listAllRecommendations } from '../../operations/recommendation'; +import { ConfigIO, JobNotFoundError, serializeResult } from '../../../lib'; +import { createJobEngine } from '../../operations/jobs'; +import { printRecommendationDetail, printRecommendationHistory } from '../../operations/jobs/recommendation/format'; +import { runCliCommand } from '../../telemetry/cli-command-run'; +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; import type { Command } from '@commander-js/extra-typings'; -import { Text, render } from 'ink'; -import React from 'react'; export const registerRecommendations = (program: Command) => { const recCmd = program.command('recommendations').description(COMMAND_DESCRIPTIONS.recommendations); recCmd .command('history') - .description('Show past recommendation runs saved locally') + .description('List recommendation jobs (running jobs are refreshed from the service)') .option('--json', 'Output as JSON') .action((cliOptions: { json?: boolean }) => { requireProject(); - - try { - const records = listAllRecommendations(); - + return runCliCommand('job.history', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + const records = await engine.list({ type: 'recommendation' }); if (cliOptions.json) { - console.log(JSON.stringify({ success: true, recommendations: records })); - process.exit(0); - return; - } - - if (records.length === 0) { - console.log('No recommendation runs found. Run `agentcore run recommendation` to create one.'); - return; - } - - console.log( - `\n${'Date'.padEnd(22)} ${'Type'.padEnd(20)} ${'Agent'.padEnd(20)} ${'Recommendation ID'.padEnd(40)}` - ); - console.log('─'.repeat(105)); - - for (const record of records) { - const date = record.startedAt - ? new Date(record.startedAt).toLocaleString([], { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) - : 'unknown'; console.log( - `${date.padEnd(22)} ${(record.type ?? 'unknown').padEnd(20)} ${(record.agent ?? 'unknown').padEnd(20)} ${record.recommendationId.padEnd(40)}` + JSON.stringify({ + success: true, + recommendations: records, + }) ); + } else { + printRecommendationHistory(records); } + return { job_type: 'recommendation' }; + }); + }); - console.log(''); - } catch (error) { + // Bare positional on the group: `agentcore recommendations ` shows one job. + // (No .description() here — that would override the group description shown in the command list.) + recCmd + .argument('', 'Recommendation job ID to view') + .option('--json', 'Output as JSON') + .action((id: string, cliOptions: { json?: boolean }) => { + requireProject(); + return runCliCommand('job.get', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + const record = await engine.get('recommendation', id); + if (!record) { + // Throw only — runCliCommand owns error output (single JSON line in --json, stderr otherwise). + throw new JobNotFoundError(`Recommendation "${id}" not found.`); + } if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + console.log(JSON.stringify(serializeResult({ success: true, ...record }))); } else { - render(Error: {getErrorMessage(error)}); + printRecommendationDetail(record); } - process.exit(1); - } + return { job_type: 'recommendation' }; + }); }); }; diff --git a/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts index 21a6faa98..28559baf1 100644 --- a/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts +++ b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts @@ -46,9 +46,12 @@ describe('remove gateway-target command', () => { describe('remove existing-endpoint target', () => { it('removes target from gateway', async () => { - // Create a fresh gateway + // Create a fresh gateway with MCP protocol (required for mcp-server targets) const tempGateway = `TempGw${Date.now()}`; - const gwResult = await runCLI(['add', 'gateway', '--name', tempGateway, '--json'], projectDir); + const gwResult = await runCLI( + ['add', 'gateway', '--name', tempGateway, '--protocol-type', 'MCP', '--json'], + projectDir + ); expect(gwResult.exitCode, `gateway add failed: ${gwResult.stdout}`).toBe(0); // Add a target to the gateway diff --git a/src/cli/commands/remove/__tests__/remove-gateway.test.ts b/src/cli/commands/remove/__tests__/remove-gateway.test.ts index cd21f3e56..63811b4e5 100644 --- a/src/cli/commands/remove/__tests__/remove-gateway.test.ts +++ b/src/cli/commands/remove/__tests__/remove-gateway.test.ts @@ -46,8 +46,8 @@ describe('remove gateway command', () => { throw new Error(`Failed to create agent: ${result.stdout} ${result.stderr}`); } - // Add gateway - result = await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], projectDir); + // Add gateway with MCP protocol (required for mcp-server targets) + result = await runCLI(['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], projectDir); if (result.exitCode !== 0) { throw new Error(`Failed to create gateway: ${result.stdout} ${result.stderr}`); } @@ -93,8 +93,8 @@ describe('remove gateway command', () => { }); it('removes gateway with targets attached', async () => { - // Re-add gateway since previous test may have removed it - await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], projectDir); + // Re-add gateway since previous test may have removed it (with MCP protocol for mcp-server targets) + await runCLI(['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], projectDir); // Add a target to the gateway const targetName = `target${Date.now()}`; diff --git a/src/cli/commands/remove/__tests__/skill-command.test.ts b/src/cli/commands/remove/__tests__/skill-command.test.ts new file mode 100644 index 000000000..77ae950a9 --- /dev/null +++ b/src/cli/commands/remove/__tests__/skill-command.test.ts @@ -0,0 +1,147 @@ +import type { HarnessSpec } from '../../../../schema'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockReadHarnessSpec = vi.fn(); +const mockWriteHarnessSpec = vi.fn(); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readHarnessSpec = mockReadHarnessSpec; + writeHarnessSpec = mockWriteHarnessSpec; + }, + findConfigRoot: vi.fn(() => '/fake/path'), +})); + +function makeHarnessSpec(skills: HarnessSpec['skills'] = []): HarnessSpec { + return { + name: 'TestHarness', + model: { provider: 'bedrock', modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0' }, + tools: [], + skills, + } as HarnessSpec; +} + +describe('handleRemoveSkill', () => { + beforeEach(() => { + mockReadHarnessSpec.mockReset(); + mockWriteHarnessSpec.mockReset(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('removes a path skill by matching value', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec([{ path: './skill-a' }, { path: './skill-b' }])); + const { handleRemoveSkill } = await import('../skill-command'); + const result = await handleRemoveSkill({ harness: 'TestHarness', path: './skill-a' }); + expect(result.success).toBe(true); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'TestHarness', + expect.objectContaining({ + skills: [{ path: './skill-b' }], + }) + ); + }); + + it('removes an s3 skill by matching value', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec([{ s3Uri: 's3://bucket/skill' }])); + const { handleRemoveSkill } = await import('../skill-command'); + const result = await handleRemoveSkill({ harness: 'TestHarness', s3: 's3://bucket/skill' }); + expect(result.success).toBe(true); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'TestHarness', + expect.objectContaining({ + skills: [], + }) + ); + }); + + it('removes a git skill by matching URL and path', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec([{ gitUrl: 'https://github.com/org/repo', path: 'sub' }])); + const { handleRemoveSkill } = await import('../skill-command'); + const result = await handleRemoveSkill({ + harness: 'TestHarness', + git: 'https://github.com/org/repo', + gitPath: 'sub', + }); + expect(result.success).toBe(true); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'TestHarness', + expect.objectContaining({ + skills: [], + }) + ); + }); + + it('removes a git skill without path by URL only', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec([{ gitUrl: 'https://github.com/org/repo' }])); + const { handleRemoveSkill } = await import('../skill-command'); + const result = await handleRemoveSkill({ harness: 'TestHarness', git: 'https://github.com/org/repo' }); + expect(result.success).toBe(true); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'TestHarness', + expect.objectContaining({ + skills: [], + }) + ); + }); + + it('removes an aws skill by matching paths', async () => { + mockReadHarnessSpec.mockResolvedValue( + makeHarnessSpec([{ awsSkills: { paths: ['core-skills/*'] } }, { path: './other' }]) + ); + const { handleRemoveSkill } = await import('../skill-command'); + const result = await handleRemoveSkill({ harness: 'TestHarness', awsSkills: 'core-skills/*' }); + expect(result.success).toBe(true); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'TestHarness', + expect.objectContaining({ + skills: [{ path: './other' }], + }) + ); + }); + + it('removes an aws skill with no paths (wildcard)', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec([{ awsSkills: {} }])); + const { handleRemoveSkill } = await import('../skill-command'); + const result = await handleRemoveSkill({ harness: 'TestHarness', awsSkills: '*' }); + expect(result.success).toBe(true); + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'TestHarness', + expect.objectContaining({ + skills: [], + }) + ); + }); + + it('fails when skill not found', async () => { + mockReadHarnessSpec.mockResolvedValue(makeHarnessSpec([{ path: './other' }])); + const { handleRemoveSkill } = await import('../skill-command'); + const result = await handleRemoveSkill({ harness: 'TestHarness', path: './missing' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('not found'); + }); + + it('fails when no source provided', async () => { + const { handleRemoveSkill } = await import('../skill-command'); + const result = await handleRemoveSkill({ harness: 'TestHarness' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('Exactly one'); + }); + + it('fails when multiple sources provided', async () => { + const { handleRemoveSkill } = await import('../skill-command'); + const result = await handleRemoveSkill({ harness: 'TestHarness', path: './x', s3: 's3://y' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('Exactly one'); + }); + + it('fails when harness not found', async () => { + mockReadHarnessSpec.mockRejectedValue(new Error('not found')); + const { handleRemoveSkill } = await import('../skill-command'); + const result = await handleRemoveSkill({ harness: 'Missing', path: './x' }); + expect(result.success).toBe(false); + expect(!result.success && result.error.message).toContain('not found'); + }); +}); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 496669ef2..b5e298047 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -73,6 +73,7 @@ async function handleRemoveAll(options: RemoveAllOptions): Promise }, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -80,7 +81,6 @@ async function handleRemoveAll(options: RemoveAllOptions): Promise policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], diff --git a/src/cli/commands/remove/skill-command.ts b/src/cli/commands/remove/skill-command.ts new file mode 100644 index 000000000..6fd4030fc --- /dev/null +++ b/src/cli/commands/remove/skill-command.ts @@ -0,0 +1,138 @@ +import { ConfigIO, findConfigRoot } from '../../../lib'; +import type { HarnessSpec } from '../../../schema'; +import { getErrorMessage } from '../../errors'; +import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; +import { buildGitSkillKey, getSkillKey } from '@/cli/operations/harness/skill-utils'; +import { ValidationError } from '@/lib/errors/types'; +import type { Result } from '@/lib/result'; +import type { Command } from '@commander-js/extra-typings'; + +export interface RemoveSkillOptions { + harness: string; + path?: string; + s3?: string; + git?: string; + gitPath?: string; + awsSkills?: string | true; +} + +export async function handleRemoveSkill( + options: RemoveSkillOptions +): Promise> { + const { harness } = options; + + const sourceCount = [options.path, options.s3, options.git, options.awsSkills].filter(Boolean).length; + if (sourceCount !== 1) { + return { + success: false, + error: new ValidationError( + 'Exactly one of --path, --s3, --git, or --aws-skills is required to identify the skill' + ), + }; + } + + const configIO = new ConfigIO(); + + let harnessSpec: HarnessSpec; + try { + harnessSpec = await configIO.readHarnessSpec(harness); + } catch { + return { success: false, error: new ValidationError(`Harness '${harness}' not found.`) }; + } + + let targetKey: string; + let skillSource: string; + if (options.path) { + targetKey = `path:${options.path}`; + skillSource = options.path; + } else if (options.s3) { + targetKey = `s3:${options.s3}`; + skillSource = options.s3; + } else if (options.git) { + targetKey = buildGitSkillKey(options.git, options.gitPath); + skillSource = options.gitPath ? `${options.git} (path: ${options.gitPath})` : options.git; + } else { + const awsPaths = + options.awsSkills === true + ? '*' + : options + .awsSkills!.split(',') + .map(s => s.trim()) + .filter(Boolean) + .sort() + .join(','); + targetKey = `awsSkills:${awsPaths}`; + skillSource = awsPaths === '*' ? 'aws-skills (all)' : `aws-skills (${awsPaths})`; + } + + const idx = harnessSpec.skills.findIndex(s => getSkillKey(s) === targetKey); + if (idx === -1) { + const hint = options.git && !options.gitPath ? ' If the skill has a sub-path, specify --git-path.' : ''; + return { + success: false, + error: new ValidationError(`Skill '${skillSource}' not found in harness '${harness}'.${hint}`), + }; + } + + harnessSpec.skills.splice(idx, 1); + await configIO.writeHarnessSpec(harness, harnessSpec); + + return { success: true, harnessName: harness, skillSource }; +} + +export function registerRemoveSkill(removeCmd: Command): void { + removeCmd + .command('skill') + .description('Remove a skill from a harness') + .requiredOption('--harness ', 'Target harness name') + .option('--path ', 'Path to an installed skill in the environment') + .option('--s3 ', 'S3 URI of skill to remove') + .option('--git ', 'Git URL of skill to remove') + .option('--git-path ', 'Subdirectory within the git repo (for --git)') + .option('--aws-skills [paths]', 'AWS skill paths to remove (comma-separated, or omit for wildcard)') + .option('--json', 'Output as JSON') + .action(async cliOptions => { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + try { + const result = await withCommandRunTelemetry('remove.skill', {}, () => + handleRemoveSkill({ + harness: cliOptions.harness, + path: cliOptions.path, + s3: cliOptions.s3, + git: cliOptions.git, + gitPath: cliOptions.gitPath, + awsSkills: cliOptions.awsSkills, + }) + ); + + if (!result.success) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: result.error.message })); + } else { + console.error(result.error.message); + } + process.exit(1); + } + + if (cliOptions.json) { + console.log( + JSON.stringify({ success: true, harnessName: result.harnessName, skillSource: result.skillSource }) + ); + } else { + console.log(`Removed skill '${result.skillSource}' from harness '${result.harnessName}'.`); + console.log(`Run 'agentcore deploy' to apply changes.`); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(getErrorMessage(error)); + } + process.exit(1); + } + }); +} diff --git a/src/cli/commands/remove/types.ts b/src/cli/commands/remove/types.ts index b829199d1..2e3ad6346 100644 --- a/src/cli/commands/remove/types.ts +++ b/src/cli/commands/remove/types.ts @@ -10,11 +10,12 @@ export type ResourceType = | 'credential' | 'evaluator' | 'online-eval' + | 'online-insights' | 'policy-engine' | 'policy' | 'config-bundle' - | 'ab-test' | 'dataset' + | 'knowledge-base' | 'payment-manager' | 'payment-connector'; diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index dcac6dac1..db036ce8d 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -1,20 +1,22 @@ -import { serializeResult } from '../../../lib'; +import { ConfigIO, ValidationError, findConfigRoot, serializeResult } from '../../../lib'; import type { RecommendationType } from '../../aws/agentcore-recommendation'; import { COMMAND_DESCRIPTIONS } from '../../constants'; import { getErrorMessage } from '../../errors'; import { handleRunEval } from '../../operations/eval'; import type { RunEvalOptions } from '../../operations/eval'; -import { saveBatchEvalRun } from '../../operations/eval/batch-eval-storage'; -import { runBatchEvaluationCommand } from '../../operations/eval/run-batch-evaluation'; +import { runKbIngestionByName } from '../../operations/ingest'; +import { createJobEngine, runDatasetPhase1, waitForTerminal } from '../../operations/jobs'; import type { - BatchEvaluationResult, - RunBatchEvaluationCommandResult, -} from '../../operations/eval/run-batch-evaluation'; -import { - applyRecommendationToBundle, - runRecommendationCommand, - saveRecommendationRun, -} from '../../operations/recommendation'; + ABTestJobRecord, + ABTestMode, + BatchEvaluationJobRecord, + InsightsJobRecord, + RecommendationJobRecord, + StartABTestJobOptions, + StartBatchEvaluationJobOptions, +} from '../../operations/jobs'; +import { printABTestDetail } from '../../operations/jobs/ab-test/format'; +import { runCliCommand } from '../../telemetry/cli-command-run'; import { requireProject } from '../../tui/guards'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; @@ -131,6 +133,18 @@ export const registerRun = (program: Command) => { process.exit(1); } + // --dataset-version only applies to dataset-driven runs. Passing it without --dataset + // would otherwise be silently ignored and the eval would fall back to trace-based evaluation. + if (cliOptions.datasetVersion && !cliOptions.dataset) { + const error = '--dataset-version requires --dataset (the version selects a version of that dataset)'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + render({error}); + } + process.exit(1); + } + const options: RunEvalOptions = { agent: cliOptions.runtime, agentArn: cliOptions.runtimeArn, @@ -178,9 +192,10 @@ export const registerRun = (program: Command) => { runCmd .command('batch-evaluation') - .description('[preview] Run evaluators in batch across all agent sessions in CloudWatch') - .requiredOption('-r, --runtime ', 'Runtime name from project config') - .requiredOption('-e, --evaluator ', 'Evaluator name(s) — Builtin.* IDs') + .description('Run evaluators in batch across all agent sessions in CloudWatch') + .option('-r, --runtime ', 'Runtime name from project config [non-interactive]') + .option('-e, --evaluator ', 'Evaluator name(s) — Builtin.* IDs [non-interactive]') + .option('--evaluator-arn ', 'Evaluator ARN(s) — use instead of -e when referencing evaluators by ARN') .option('-n, --name ', 'Name for the batch evaluation (auto-generated if omitted)') .option('-d, --lookback-days ', 'Lookback window in days (filters sessions by time range)') .option('-s, --session-ids ', 'Specific session IDs to evaluate') @@ -195,11 +210,14 @@ export const registerRun = (program: Command) => { ) .option('--dataset ', 'Dataset name — invoke agent with dataset scenarios before batch evaluation') .option('--dataset-version ', 'Dataset version to use (omit for local file, or N/DRAFT)') + .option('--kms-key ', 'KMS key ARN for encrypting batch evaluation results') + .option('--wait', 'Block until the batch evaluation reaches a terminal state') .option('--json', 'Output as JSON') .action( async (cliOptions: { - runtime: string; - evaluator: string[]; + runtime?: string; + evaluator?: string[]; + evaluatorArn?: string[]; name?: string; lookbackDays?: string; sessionIds?: string[]; @@ -208,94 +226,231 @@ export const registerRun = (program: Command) => { endpoint?: string; dataset?: string; datasetVersion?: string; + kmsKey?: string; + wait?: boolean; json?: boolean; }) => { requireProject(); - try { - // Parse ground truth file if provided - let sessionMetadata: import('../../aws/agentcore-batch-evaluation').SessionMetadataEntry[] | undefined; + if (!cliOptions.runtime && !cliOptions.json) { + const { requireTTY } = await import('../../tui/guards/tty'); + requireTTY(); + const { RunBatchEvalFlow } = await import('../../tui/screens/run-eval/RunBatchEvalFlow'); + const { clear, unmount } = render( + { + clear(); + unmount(); + process.exit(0); + }} + /> + ); + return; + } + + if (!cliOptions.runtime || (!cliOptions.evaluator?.length && !cliOptions.evaluatorArn?.length)) { + const error = + '--runtime and at least one --evaluator or --evaluator-arn are required in non-interactive mode'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + render({error}); + } + process.exit(1); + } + + // --dataset-version only applies to dataset-driven runs. Passing it without --dataset + // would otherwise be silently ignored and the job would fall back to trace-based evaluation. + if (cliOptions.datasetVersion && !cliOptions.dataset) { + const error = '--dataset-version requires --dataset (the version selects a version of that dataset)'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + render({error}); + } + process.exit(1); + } + + const log = (message: string) => { + if (!cliOptions.json) console.log(message); + }; + + await runCliCommand('run.job', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + + // Ground truth file (explicit sessionMetadata) + let sessionMetadata: StartBatchEvaluationJobOptions['sessionMetadata']; if (cliOptions.groundTruth) { const { readFileSync } = await import('node:fs'); - const gtContent = readFileSync(cliOptions.groundTruth, 'utf-8'); - const gtData = JSON.parse(gtContent) as Record; - // Accept either a raw array or an object with a sessionMetadata key - sessionMetadata = Array.isArray(gtData) - ? (gtData as import('../../aws/agentcore-batch-evaluation').SessionMetadataEntry[]) - : (gtData.sessionMetadata as import('../../aws/agentcore-batch-evaluation').SessionMetadataEntry[]); - if (!Array.isArray(sessionMetadata)) { + const gtData = JSON.parse(readFileSync(cliOptions.groundTruth, 'utf-8')) as Record; + const parsed = Array.isArray(gtData) ? gtData : gtData.sessionMetadata; + if (!Array.isArray(parsed)) { throw new Error( 'Ground truth file must be a JSON array of session metadata entries, or an object with a "sessionMetadata" key' ); } + sessionMetadata = parsed as StartBatchEvaluationJobOptions['sessionMetadata']; } const lookbackDays = cliOptions.lookbackDays ? parseInt(cliOptions.lookbackDays, 10) : undefined; - const result = await runBatchEvaluationCommand({ - agent: cliOptions.runtime, - evaluators: cliOptions.evaluator, + if (lookbackDays !== undefined && (isNaN(lookbackDays) || lookbackDays < 1 || lookbackDays > 90)) { + throw new Error('--lookback-days must be between 1 and 90'); + } + let sessionIds = cliOptions.sessionIds; + const datasetInfo = cliOptions.dataset + ? { id: cliOptions.dataset, version: cliOptions.datasetVersion ?? 'LOCAL' } + : undefined; + + // Dataset mode (Phase-1): invoke scenarios + wait for ingestion, then start (caller-side, blocking). + if (cliOptions.dataset) { + const phase1 = await runDatasetPhase1({ + agent: cliOptions.runtime!, + datasetName: cliOptions.dataset, + datasetVersion: cliOptions.datasetVersion, + endpoint: cliOptions.endpoint, + onProgress: (_phase, message) => log(message), + }); + if (!phase1.success) { + throw phase1.error; + } + sessionIds = [...(sessionIds ?? []), ...phase1.sessionIds]; + sessionMetadata = [...(sessionMetadata ?? []), ...phase1.sessionMetadata]; + } + + const evaluators = [...(cliOptions.evaluator ?? []), ...(cliOptions.evaluatorArn ?? [])]; + + const startResult = await engine.start('batch-evaluation', { + agent: cliOptions.runtime!, + evaluators, name: cliOptions.name, region: cliOptions.region, endpoint: cliOptions.endpoint, - sessionIds: cliOptions.sessionIds, + sessionIds, lookbackDays: lookbackDays && !isNaN(lookbackDays) ? lookbackDays : undefined, sessionMetadata, - dataset: cliOptions.dataset, - datasetVersion: cliOptions.datasetVersion, - onProgress: cliOptions.json - ? undefined - : (_status, message) => { - console.log(message); - }, + source: cliOptions.dataset ? 'dataset' : 'traces', + dataset: datasetInfo, + kmsKeyArn: cliOptions.kmsKey, + onProgress: cliOptions.json ? undefined : (_status, message) => console.log(message), }); + if (!startResult.success) { + throw startResult.error; + } + let record: BatchEvaluationJobRecord = startResult.record; - // Save results locally - if (result.success) { - try { - const datasetInfo = cliOptions.dataset - ? { - source: 'dataset', - dataset: { - id: cliOptions.dataset, - version: cliOptions.datasetVersion ?? 'LOCAL', - }, - } - : {}; - const filePath = saveBatchEvalRun({ result, ...datasetInfo }); - if (!cliOptions.json) { - console.log(`\nResults saved to: ${filePath}`); - } - } catch { - // Non-fatal — skip saving - } + if (cliOptions.wait) { + const final = await waitForTerminal(engine, 'batch-evaluation', record.id, { + onTick: status => log(`Status: ${status}`), + }); + if (final) record = final; } if (cliOptions.json) { - console.log(JSON.stringify(serializeResult(result))); - } else if (result.success) { - formatBatchEvalOutput(result); + console.log(JSON.stringify(serializeResult({ success: true, ...record }))); } else { - render({result.error.message}); - if (result.logFilePath) { - console.error(`\nLog: ${result.logFilePath}`); + console.log(`\n✓ Batch evaluation started: ${record.id} (${record.status})`); + printBatchEvalResult(record); + if (!cliOptions.wait) { + console.log(`\nNext: agentcore view batch-evaluation ${record.id}`); } + console.log(''); + } + return { job_type: 'batch-evaluation', has_wait: !!cliOptions.wait }; + }); + } + ); + + runCmd + .command('insights') + .description('[preview] Run failure analysis across agent sessions') + .option('-r, --runtime ', 'Runtime name from project config') + .option('--insights ', 'Insight type(s) (default: Builtin.Insight.FailureAnalysis)') + .option('-e, --evaluator ', 'Evaluator(s) to include (needed for chaining into recommendations)') + .option('--online-eval-config-arn ', 'Use an existing OnlineEvaluationConfig as session source') + .option('-d, --lookback-days ', 'Lookback window in days (default: 7)') + .option('--start-time ', 'Session filter start time') + .option('--end-time ', 'Session filter end time') + .option('-s, --session-ids ', 'Limit to specific session IDs') + .option('-n, --name ', 'Job name (auto-generated if omitted)') + .option('--wait', 'Block until the job reaches a terminal state') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--endpoint ', 'Runtime endpoint name (e.g. PROMPT_V1)') + .option('--json', 'Output as JSON') + .action( + async (cliOptions: { + runtime?: string; + insights?: string[]; + evaluator?: string[]; + onlineEvalConfigArn?: string; + lookbackDays?: string; + startTime?: string; + endTime?: string; + sessionIds?: string[]; + name?: string; + wait?: boolean; + region?: string; + endpoint?: string; + json?: boolean; + }) => { + requireProject(); + + const log = (message: string) => { + if (!cliOptions.json) console.log(message); + }; + + await runCliCommand('run.job', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + + const insightIds = cliOptions.insights ?? ['Builtin.Insight.FailureAnalysis']; + const lookbackDays = cliOptions.lookbackDays ? parseInt(cliOptions.lookbackDays, 10) : undefined; + if (lookbackDays !== undefined && (isNaN(lookbackDays) || lookbackDays < 1 || lookbackDays > 90)) { + throw new Error('--lookback-days must be between 1 and 90'); + } + + const startResult = await engine.start('insights', { + agent: cliOptions.runtime, + insights: insightIds, + evaluators: cliOptions.evaluator, + onlineEvalConfigArn: cliOptions.onlineEvalConfigArn, + lookbackDays: lookbackDays && !isNaN(lookbackDays) ? lookbackDays : undefined, + startTime: cliOptions.startTime, + endTime: cliOptions.endTime, + sessionIds: cliOptions.sessionIds, + name: cliOptions.name, + region: cliOptions.region, + endpoint: cliOptions.endpoint, + onProgress: cliOptions.json ? undefined : (_status, message) => console.log(message), + }); + if (!startResult.success) { + throw startResult.error; + } + let record: InsightsJobRecord = startResult.record; + + if (cliOptions.wait) { + const final = await waitForTerminal(engine, 'insights', record.id, { + onTick: status => log(`Status: ${status}`), + }); + if (final) record = final; } - process.exit(result.success ? 0 : 1); - } catch (error) { if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + console.log(JSON.stringify(serializeResult({ success: true, ...record }))); } else { - render(Error: {getErrorMessage(error)}); + console.log(`\n✓ Insights job started: ${record.id} (${record.status})`); + printInsightsResult(record); + if (!cliOptions.wait) { + console.log(`\nNext: agentcore insights results --id ${record.id}`); + } + console.log(''); } - process.exit(1); - } + return { job_type: 'insights', has_wait: !!cliOptions.wait }; + }); } ); runCmd .command('recommendation') - .description('[preview] Optimize a system prompt or tool descriptions using agent traces as signal') + .description('Optimize a system prompt or tool descriptions using agent traces as signal') .option('-t, --type ', 'What to optimize: system-prompt or tool-description (default: system-prompt)') .option('-r, --runtime ', 'Runtime name from project config') .option('-e, --evaluator ', 'Evaluator name — required for system-prompt (exactly one)') @@ -320,6 +475,10 @@ export const registerRun = (program: Command) => { .option('-s, --session-id ', 'Limit trace collection to specific session IDs') .option('-n, --run ', 'Run name prefix for the recommendation') .option('--region ', 'AWS region') + .option('--kms-key ', 'KMS key ARN for encrypting recommendation results') + .option('--from-insights ', 'Use a local insights run as trace source (resolves batch eval ARN)') + .option('--batch-evaluation-arn ', 'Use a batch evaluation ARN directly as trace source') + .option('--wait', 'Block until the recommendation reaches a terminal state') .option('--json', 'Output as JSON') .action( async (cliOptions: { @@ -338,10 +497,30 @@ export const registerRun = (program: Command) => { sessionId?: string[]; run?: string; region?: string; + kmsKey?: string; + fromInsights?: string; + batchEvaluationArn?: string; + wait?: boolean; json?: boolean; }) => { requireProject(); + if (!cliOptions.runtime && !cliOptions.json) { + const { requireTTY } = await import('../../tui/guards/tty'); + requireTTY(); + const { RecommendationFlow } = await import('../../tui/screens/recommendation/RecommendationFlow'); + const { clear, unmount } = render( + { + clear(); + unmount(); + process.exit(0); + }} + /> + ); + return; + } + const typeKey = cliOptions.type ?? 'system-prompt'; const recType = RECOMMENDATION_TYPE_MAP[typeKey]; if (!recType) { @@ -354,11 +533,12 @@ export const registerRun = (program: Command) => { process.exit(1); } + const isBatchEvalSource = !!(cliOptions.fromInsights ?? cliOptions.batchEvaluationArn); const agent = cliOptions.runtime; const evaluator = cliOptions.evaluator; - if (!agent) { - const error = '--runtime is required'; + if (!agent && !isBatchEvalSource) { + const error = '--runtime is required (unless --from-insights or --batch-evaluation-arn is provided)'; if (cliOptions.json) { console.log(JSON.stringify({ success: false, error })); } else { @@ -367,9 +547,9 @@ export const registerRun = (program: Command) => { process.exit(1); } - // Evaluator is required for system-prompt recs, optional for tool-description - if (recType === 'SYSTEM_PROMPT_RECOMMENDATION' && !evaluator) { - const error = '--evaluator is required for system-prompt recommendations'; + // Evaluator is required for system-prompt recs, optional for tool-description and batch-eval source + if (recType === 'SYSTEM_PROMPT_RECOMMENDATION' && !evaluator && !isBatchEvalSource) { + const error = '--evaluator is required for system-prompt recommendations (unless using --from-insights)'; if (cliOptions.json) { console.log(JSON.stringify({ success: false, error })); } else { @@ -378,36 +558,39 @@ export const registerRun = (program: Command) => { process.exit(1); } - try { - const inputSource = cliOptions.promptFile - ? ('file' as const) - : cliOptions.inline - ? ('inline' as const) - : cliOptions.bundleName - ? ('config-bundle' as const) - : ('inline' as const); - - const traceSource = cliOptions.spansFile + const inputSource = cliOptions.promptFile + ? ('file' as const) + : cliOptions.inline + ? ('inline' as const) + : cliOptions.bundleName + ? ('config-bundle' as const) + : ('inline' as const); + + const traceSource = isBatchEvalSource + ? ('batch-evaluation' as const) + : cliOptions.spansFile ? ('spans-file' as const) : cliOptions.sessionId ? ('sessions' as const) : ('cloudwatch' as const); - // Parse --tool-desc-json-path pairs ("toolName:$.json.path") into structured format - const toolDescJsonPaths = cliOptions.toolDescJsonPath - ?.map(pair => { - const colonIdx = pair.indexOf(':'); - if (colonIdx <= 0) return undefined; - return { - toolName: pair.slice(0, colonIdx), - toolDescriptionJsonPath: pair.slice(colonIdx + 1), - }; - }) - .filter((p): p is { toolName: string; toolDescriptionJsonPath: string } => p !== undefined); - - const result = await runRecommendationCommand({ + // Parse --tool-desc-json-path pairs ("toolName:$.json.path") into structured format + const toolDescJsonPaths = cliOptions.toolDescJsonPath + ?.map(pair => { + const colonIdx = pair.indexOf(':'); + if (colonIdx <= 0) return undefined; + return { + toolName: pair.slice(0, colonIdx), + toolDescriptionJsonPath: pair.slice(colonIdx + 1), + }; + }) + .filter((p): p is { toolName: string; toolDescriptionJsonPath: string } => p !== undefined); + + await runCliCommand('run.job', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + const startResult = await engine.start('recommendation', { type: recType, - agent, + agent: agent ?? '', evaluators: evaluator ? [evaluator] : [], promptFile: cliOptions.promptFile, inlineContent: cliOptions.inline, @@ -419,114 +602,372 @@ export const registerRun = (program: Command) => { lookbackDays: parseInt(cliOptions.lookback, 10), sessionIds: cliOptions.sessionId, spansFile: cliOptions.spansFile, + fromInsights: cliOptions.fromInsights, + batchEvaluationArn: cliOptions.batchEvaluationArn, recommendationName: cliOptions.run, region: cliOptions.region, + kmsKeyArn: cliOptions.kmsKey, inputSource, traceSource, - onProgress: cliOptions.json - ? undefined - : (_status, message) => { - console.log(message); - }, + onProgress: cliOptions.json ? undefined : (_status, message) => console.log(message), }); - if (!result.success) { - if (cliOptions.json) { - console.log(JSON.stringify(serializeResult(result))); - } else { - render({result.error.message}); - if (result.logFilePath) { - console.error(`\nLog: ${result.logFilePath}`); - } - } - process.exit(1); + if (!startResult.success) { + throw startResult.error; } - - // Save results locally - let savedFilePath: string | undefined; - try { - if (result.recommendationId) { - savedFilePath = saveRecommendationRun( - result.recommendationId, - result, - recType, - agent, - evaluator ? [evaluator] : [] - ); - } - } catch { - // Non-fatal — skip saving + let record: RecommendationJobRecord = startResult.record; + + if (cliOptions.wait) { + const final = await waitForTerminal(engine, 'recommendation', record.id, { + onTick: status => { + if (!cliOptions.json) console.log(`Status: ${status}`); + }, + }); + if (final) record = final; } if (cliOptions.json) { - console.log(JSON.stringify(serializeResult(result))); + console.log(JSON.stringify(serializeResult({ success: true, ...record }))); } else { - console.log(`\nRecommendation ID: ${result.recommendationId}`); - - if (result.result) { - const sysResult = result.result.systemPromptRecommendationResult; - const toolResult = result.result.toolDescriptionRecommendationResult; - - if (sysResult) { - if (sysResult.recommendedSystemPrompt) { - console.log('\n+++ Recommended System Prompt +++'); - console.log(sysResult.recommendedSystemPrompt); - } - } else if (toolResult?.tools) { - for (const tool of toolResult.tools) { - console.log(`\nTool: ${tool.toolName}`); - console.log(`Recommended: ${tool.recommendedToolDescription}`); - } - } - } - - if (savedFilePath) { - console.log(`\nResults saved to: ${savedFilePath}`); - } - - // Sync local config bundle after server-side recommendation apply - if (inputSource === 'config-bundle' && cliOptions.bundleName && result.result && result.region) { - try { - const applyResult = await applyRecommendationToBundle({ - bundleName: cliOptions.bundleName, - result: result.result, - region: result.region, - }); - if (applyResult.success) { - console.log( - `\nA new config bundle version (${applyResult.newVersionId}) was created with the recommended changes.` - ); - console.log(`Local config for "${cliOptions.bundleName}" has been updated to match.`); - } else { - console.log(`\nCould not sync config bundle: ${applyResult.error.message}`); - } - } catch { - // Non-fatal — user can manually sync - } + console.log(`\n✓ Recommendation started: ${record.id} (${record.status})`); + printRecommendationResult(record); + if (!cliOptions.wait) { + console.log( + `\nNext: agentcore view recommendation ${record.id}` + + (inputSource === 'config-bundle' + ? ' — the new config bundle will be applied to agentcore.json automatically.' + : '') + ); } console.log(''); } + return { job_type: 'recommendation', has_wait: !!cliOptions.wait }; + }); + } + ); - process.exit(0); - } catch (error) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); - } else { - render(Error: {getErrorMessage(error)}); + // ────────────────────────────────────────────────────────────────────── + // run ingest — manually trigger ingestion for a deployed knowledge base. + // + // Drift correction #4 from Plan C: 2-deep nesting (`run ingest`), not + // `run ingest knowledge-base`. KBs are the only ingestible resource for + // now; future ingestible types could add a --type flag. + // ────────────────────────────────────────────────────────────────────── + runCmd + .command('ingest') + .description('Start a fresh ingestion job for every data source on a deployed knowledge base.') + .option('--name ', 'Knowledge base name (must exist in agentcore.json)') + .option('--target ', 'Deployment target name (defaults to "default")', 'default') + .option('--data-source ', 'Ingest only the data source with this URI (default: all sources)') + .option('--json', 'Output as JSON [non-interactive]') + .action(async (cliOptions: { name?: string; target?: string; dataSource?: string; json?: boolean }) => { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + await runCliCommand('run.ingest', !!cliOptions.json, async () => { + if (!cliOptions.name) { + throw new ValidationError('A --name is required for `agentcore run ingest`.'); + } + const targetName = cliOptions.target ?? 'default'; + + const configIO = new ConfigIO(); + const [project, awsTargets, deployedState] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readAWSDeploymentTargets(), + configIO.readDeployedState().catch(() => ({ targets: {} })), + ]); + + const kbExists = (project.knowledgeBases ?? []).some(kb => kb.name === cliOptions.name); + if (!kbExists) { + throw new ValidationError(`Knowledge base '${cliOptions.name}' is not in agentcore.json.`); + } + const target = awsTargets.find(t => t.name === targetName); + if (!target) { + throw new ValidationError(`Deployment target '${targetName}' is not in aws-targets.json.`); + } + + // Wire Ctrl+C → AbortController so the user can bail out of long + // retry sleeps cleanly. Progress lines go to stderr so --json stdout + // remains a single parseable object. + const abortController = new AbortController(); + const onSigint = () => abortController.abort(); + process.once('SIGINT', onSigint); + let result; + try { + result = await runKbIngestionByName({ + knowledgeBaseName: cliOptions.name, + deployedState, + targetName, + region: target.region, + dataSourceUri: cliOptions.dataSource, + signal: abortController.signal, + onProgress: cliOptions.json ? undefined : msg => process.stderr.write(`${msg}\n`), + }); + } finally { + process.off('SIGINT', onSigint); + } + + if (!result.success) { + throw result.error; + } + + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, startedJobs: result.startedJobs })); + } else { + console.log(`Started ingestion for '${cliOptions.name}' (${result.startedJobs.length} data source(s)):`); + for (const job of result.startedJobs) { + console.log(` ${job.uri} → ${job.ingestionJobId}`); } - process.exit(1); + console.log(`\nRun 'agentcore status' to track progress.`); } - } + + return { data_source_count: result.startedJobs.length }; + }); + }); + const abTestCmd = runCmd + .command('ab-test') + .description('Start an A/B test comparing two config-bundle or gateway-target variants') + // ── Shared options ── + .option('-n, --name ', 'Name for the A/B test [non-interactive]') + .option('-g, --gateway ', 'Gateway name (must be deployed) [non-interactive]') + .option('-m, --mode ', 'config-bundle | target-based (default: config-bundle)', 'config-bundle') + .option('--description ', 'Description') + .option('-r, --runtime ', 'Runtime name (recorded as the agent)') + .option('--control-weight ', 'Control traffic weight 0-100 (default: 50)', '50') + .option('--treatment-weight ', 'Treatment traffic weight 0-100 (default: 50)', '50') + .option('--role-arn ', 'Execution role ARN (auto-created if omitted)') + .option('--disable-on-create', 'Create without starting (default: enabled)') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--wait', 'Block until terminal state') + .option('--json', 'Output as JSON') + // ── Config-bundle mode ── + .option('--control-bundle ', '[config-bundle] Control bundle name or ARN') + .option('--control-version ', '[config-bundle] Control bundle version (or LATEST)') + .option('--treatment-bundle ', '[config-bundle] Treatment bundle name or ARN') + .option('--treatment-version ', '[config-bundle] Treatment bundle version (or LATEST)') + .option('--online-eval ', '[config-bundle] Shared online eval config name or ARN') + // ── Target-based mode ── + .option('--control-target ', '[target-based] Control gateway-target name') + .option('--treatment-target ', '[target-based] Treatment gateway-target name') + .option('--control-online-eval ', '[target-based] Online eval for control endpoint (required)') + .option('--treatment-online-eval ', '[target-based] Online eval for treatment endpoint (required)') + .option( + '--gateway-filter ', + 'Single gateway target path pattern to scope the test (e.g. "/orders/*"). Applies to both modes. Optional; omit for no gateway filter.' ); + + abTestCmd.addHelpText( + 'after', + ` +Config-bundle mode example: + agentcore run ab-test -n MyTest -g MyGateway \\ + --control-bundle PromptV1 --control-version LATEST \\ + --treatment-bundle PromptV2 --treatment-version LATEST \\ + --online-eval QualityEval + +Target-based mode example: + agentcore run ab-test -n MyTest -g MyGateway --mode target-based \\ + --control-target prod-target --treatment-target staging-target \\ + --control-online-eval ProdEval --treatment-online-eval StagingEval +` + ); + + abTestCmd.action( + async (cliOptions: { + name?: string; + gateway?: string; + mode: string; + description?: string; + runtime?: string; + controlBundle?: string; + controlVersion?: string; + treatmentBundle?: string; + treatmentVersion?: string; + onlineEval?: string; + controlTarget?: string; + treatmentTarget?: string; + controlOnlineEval?: string; + treatmentOnlineEval?: string; + gatewayFilter?: string; + controlWeight: string; + treatmentWeight: string; + roleArn?: string; + disableOnCreate?: boolean; + region?: string; + wait?: boolean; + json?: boolean; + }) => { + requireProject(); + + if (!cliOptions.name && !cliOptions.json) { + const { requireTTY } = await import('../../tui/guards/tty'); + requireTTY(); + const { RunABTestFlow } = await import('../../tui/screens/run-ab-test/RunABTestFlow'); + const { clear, unmount } = render( + { + clear(); + unmount(); + process.exit(0); + }} + /> + ); + return; + } + + if (!cliOptions.name || !cliOptions.gateway) { + const error = '--name and --gateway are required in non-interactive mode'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + render({error}); + } + process.exit(1); + } + + if (cliOptions.mode !== 'config-bundle' && cliOptions.mode !== 'target-based') { + const error = `Invalid --mode "${cliOptions.mode}". Must be one of: config-bundle, target-based`; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + render({error}); + } + process.exit(1); + } + const mode: ABTestMode = cliOptions.mode; + + // Validate variant weights are integers in [0,100] and sum to 100. + const controlWeight = parseInt(cliOptions.controlWeight, 10); + const treatmentWeight = parseInt(cliOptions.treatmentWeight, 10); + const weightError = + isNaN(controlWeight) || controlWeight < 0 || controlWeight > 100 + ? `Invalid --control-weight "${cliOptions.controlWeight}". Must be an integer between 0 and 100.` + : isNaN(treatmentWeight) || treatmentWeight < 0 || treatmentWeight > 100 + ? `Invalid --treatment-weight "${cliOptions.treatmentWeight}". Must be an integer between 0 and 100.` + : controlWeight + treatmentWeight !== 100 + ? `Variant weights must sum to 100 (got ${controlWeight} + ${treatmentWeight} = ${controlWeight + treatmentWeight}).` + : undefined; + if (weightError) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: weightError })); + } else { + render({weightError}); + } + process.exit(1); + } + + await runCliCommand('run.job', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + const startOpts: StartABTestJobOptions = { + name: cliOptions.name!, + mode, + description: cliOptions.description, + gateway: cliOptions.gateway!, + agent: cliOptions.runtime, + controlBundle: cliOptions.controlBundle, + controlVersion: cliOptions.controlVersion, + treatmentBundle: cliOptions.treatmentBundle, + treatmentVersion: cliOptions.treatmentVersion, + onlineEval: cliOptions.onlineEval, + runtime: cliOptions.runtime, + controlTarget: cliOptions.controlTarget, + treatmentTarget: cliOptions.treatmentTarget, + controlOnlineEval: cliOptions.controlOnlineEval, + treatmentOnlineEval: cliOptions.treatmentOnlineEval, + gatewayFilter: cliOptions.gatewayFilter, + controlWeight, + treatmentWeight, + enableOnCreate: !cliOptions.disableOnCreate, + region: cliOptions.region, + roleArn: cliOptions.roleArn, + onProgress: cliOptions.json ? undefined : (_status, message) => console.log(message), + }; + + const startResult = await engine.start('ab-test', startOpts); + if (!startResult.success) { + throw startResult.error; + } + let record: ABTestJobRecord = startResult.record; + + if (cliOptions.wait) { + const final = await waitForTerminal(engine, 'ab-test', record.id, { + onTick: status => { + if (!cliOptions.json) console.log(`Status: ${status}`); + }, + }); + if (final) record = final; + } + + if (cliOptions.json) { + console.log(JSON.stringify(serializeResult({ success: true, ...record }))); + } else { + console.log(`\n✓ A/B test started: ${record.id} (${record.status})`); + printABTestDetail(record); + if (!cliOptions.wait) { + console.log(`\nNext: agentcore view ab-test ${record.id}`); + } + console.log(''); + } + return { job_type: 'ab-test', has_wait: !!cliOptions.wait }; + }); + } + ); }; -function formatBatchEvalOutput(result: RunBatchEvaluationCommandResult): void { - console.log(`\nBatch Evaluation: ${result.name ?? result.batchEvaluationId}`); - console.log(`ID: ${result.batchEvaluationId}`); - console.log(`Status: ${result.status}`); +/** Print a recommendation's optimized artifact (system prompt / tool descriptions) when available. */ +function printRecommendationResult(record: RecommendationJobRecord): void { + const sys = record.result?.systemPromptRecommendationResult; + const tool = record.result?.toolDescriptionRecommendationResult; + if (sys?.recommendedSystemPrompt) { + if (sys.explanation) { + console.log('\n--- Explanation ---'); + console.log(sys.explanation); + } + console.log('\n+++ Recommended System Prompt +++'); + console.log(sys.recommendedSystemPrompt); + } else if (tool?.tools?.length) { + for (const t of tool.tools) { + console.log(`\nTool: ${t.toolName}`); + if (t.explanation) { + console.log(`Explanation: ${t.explanation}`); + } + console.log(`Recommended: ${t.recommendedToolDescription}`); + } + } else if (record.status === 'FAILED') { + console.log(`\nError: ${record.failureDetail ?? record.statusReasons?.join('; ') ?? 'unknown'}`); + } + if (record.syncedVersionId) { + console.log(`\nNew config bundle version ${record.syncedVersionId} applied to agentcore.json.`); + } +} + +/** Print an insights job's failure analysis results. */ +function printInsightsResult(record: InsightsJobRecord): void { + const fa = record.failureAnalysisResult; + if (fa?.failureCategories?.length) { + console.log('\nFailure Analysis:'); + for (const cat of fa.failureCategories) { + console.log(` ${cat.failureCategoryName ?? 'Unknown'}: ${cat.failureCategoryDescription ?? ''}`); + if (cat.rootCauses?.length) { + for (const rc of cat.rootCauses) { + console.log(` - ${rc.rootCauseCategory ?? ''}: ${rc.rootCauseDescription ?? ''}`); + if (rc.recommendation) console.log(` Recommendation: ${rc.recommendation}`); + } + } + } + } else if (record.evaluationResults?.evaluatorSummaries?.length) { + console.log('\nEvaluation Results:'); + for (const s of record.evaluationResults.evaluatorSummaries) { + const avg = s.statistics?.averageScore; + console.log(` ${s.evaluatorId}: ${avg != null ? avg.toFixed(2) : 'N/A'}`); + } + } +} - // Show session stats from API if available - const evalResults = result.evaluationResults; +/** Print a batch evaluation's scores (server summaries preferred, CloudWatch per-session as fallback). */ +function printBatchEvalResult(record: BatchEvaluationJobRecord): void { + const evalResults = record.evaluationResults; if (evalResults) { const parts: string[] = []; if (evalResults.totalNumberOfSessions != null) parts.push(`${evalResults.totalNumberOfSessions} sessions`); @@ -535,11 +976,9 @@ function formatBatchEvalOutput(result: RunBatchEvaluationCommandResult): void { if (parts.length > 0) console.log(`Sessions: ${parts.join(', ')}`); } - console.log(''); - - // Prefer API evaluatorSummaries over local computation const summaries = evalResults?.evaluatorSummaries; if (summaries && summaries.length > 0) { + console.log('\nResults:'); for (const s of summaries) { const avg = s.statistics?.averageScore; const avgStr = avg != null ? avg.toFixed(2) : 'N/A'; @@ -547,35 +986,19 @@ function formatBatchEvalOutput(result: RunBatchEvaluationCommandResult): void { const evalCount = s.totalEvaluated != null ? ` [${s.totalEvaluated} evaluated]` : ''; console.log(` ${s.evaluatorId}: ${avgStr} avg${failSuffix}${evalCount}`); } - } else if (result.results.length > 0) { - // Fall back to local computation from CloudWatch results - const byEvaluator = new Map(); - for (const r of result.results) { + } else if (record.results?.length) { + console.log('\nResults:'); + const byEvaluator = new Map>(); + for (const r of record.results) { const group = byEvaluator.get(r.evaluatorId) ?? []; group.push(r); byEvaluator.set(r.evaluatorId, group); } - for (const [evalId, evalGroup] of byEvaluator) { - const scores = evalGroup.filter(r => !r.error).map(r => r.score!); + const scores = evalGroup.filter(r => !r.error && r.score != null).map(r => r.score!); const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0; const errors = evalGroup.filter(r => r.error).length; - const errorSuffix = errors > 0 ? ` (${errors} errors)` : ''; - - console.log(` ${evalId}: ${avg.toFixed(2)} avg${errorSuffix}`); - - for (const r of evalGroup) { - if (r.error) { - console.log(` ERROR: ${r.error.slice(0, 80)}`); - } else { - const labelStr = r.label ? ` (${r.label})` : ''; - console.log(` ${r.score?.toFixed(2)}${labelStr}`); - } - } + console.log(` ${evalId}: ${avg.toFixed(2)} avg${errors > 0 ? ` (${errors} errors)` : ''}`); } - } else { - console.log(' No evaluation results found.'); } - - console.log(''); } diff --git a/src/cli/commands/status/__tests__/action.test.ts b/src/cli/commands/status/__tests__/action.test.ts index 25520d9ce..367048370 100644 --- a/src/cli/commands/status/__tests__/action.test.ts +++ b/src/cli/commands/status/__tests__/action.test.ts @@ -8,6 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockGetAgentRuntimeStatus = vi.fn(); const mockGetEvaluator = vi.fn(); const mockGetOnlineEvaluationConfig = vi.fn(); +const mockGetKnowledgeBase = vi.fn(); +const mockGetLatestIngestionJob = vi.fn(); vi.mock('../../../aws', () => ({ getAgentRuntimeStatus: (...args: unknown[]) => mockGetAgentRuntimeStatus(...args), @@ -18,17 +20,27 @@ vi.mock('../../../aws/agentcore-control', () => ({ getOnlineEvaluationConfig: (...args: unknown[]) => mockGetOnlineEvaluationConfig(...args), })); +vi.mock('../../../aws/bedrock-agent', () => ({ + getKnowledgeBase: (...args: unknown[]) => mockGetKnowledgeBase(...args), + getLatestIngestionJob: (...args: unknown[]) => mockGetLatestIngestionJob(...args), +})); + const mockIsPreviewEnabled = vi.fn(() => true); +const mockIsGatedFeaturesEnabled = vi.fn(() => true); vi.mock('../../../feature-flags', () => ({ isPreviewEnabled: () => mockIsPreviewEnabled(), + isGatedFeaturesEnabled: () => mockIsGatedFeaturesEnabled(), })); +const loggedLines: string[] = []; vi.mock('../../../logging', () => { return { ExecLogger: class { startStep = vi.fn(); endStep = vi.fn(); - log = vi.fn(); + log = vi.fn((line: string) => { + loggedLines.push(line); + }); finalize = vi.fn(); getRelativeLogPath = vi.fn().mockReturnValue('logs/status.log'); }, @@ -41,6 +53,7 @@ const baseProject: AgentCoreProjectSpec = { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], } as unknown as AgentCoreProjectSpec; @@ -497,6 +510,51 @@ describe('computeResourceStatuses', () => { expect(harnessEntries).toHaveLength(0); }); + it('renders the config version (v{N}) on a deployed harness when gated features are enabled', () => { + const project = { + ...baseProject, + harnesses: [{ name: 'my-harness', path: 'harnesses/my-harness' }], + } as unknown as AgentCoreProjectSpec; + const resources: DeployedResourceState = { + harnesses: { + 'my-harness': { + harnessId: 'h-123', + harnessArn: 'arn:aws:bedrock:us-east-1:123456789:harness/h-123', + roleArn: 'arn:aws:iam::123456789:role/test', + status: 'READY', + harnessVersion: 3, + }, + }, + }; + + const result = computeResourceStatuses(project, resources); + const harnessEntry = result.find(r => r.resourceType === 'harness' && r.name === 'my-harness'); + expect(harnessEntry!.detail).toBe('v3'); + }); + + it('does not render the config version when gated features are disabled', () => { + mockIsGatedFeaturesEnabled.mockReturnValueOnce(false); + const project = { + ...baseProject, + harnesses: [{ name: 'my-harness', path: 'harnesses/my-harness' }], + } as unknown as AgentCoreProjectSpec; + const resources: DeployedResourceState = { + harnesses: { + 'my-harness': { + harnessId: 'h-123', + harnessArn: 'arn:aws:bedrock:us-east-1:123456789:harness/h-123', + roleArn: 'arn:aws:iam::123456789:role/test', + status: 'READY', + harnessVersion: 3, + }, + }, + }; + + const result = computeResourceStatuses(project, resources); + const harnessEntry = result.find(r => r.resourceType === 'harness' && r.name === 'my-harness'); + expect(harnessEntry!.detail).toBeUndefined(); + }); + it('handles mixed deployed and local-only resources', () => { const project = { ...baseProject, @@ -538,6 +596,179 @@ describe('computeResourceStatuses', () => { const deployedCred = result.find(r => r.name === 'deployed-cred'); expect(deployedCred!.deploymentState).toBe('deployed'); }); + + it('marks knowledge-base as deployed when present in deployed-state', () => { + const project = { + ...baseProject, + knowledgeBases: [ + { + type: 'AgentCoreKnowledgeBase', + name: 'product-docs', + dataSources: [{ type: 'S3', uri: 's3://b/d/' }], + }, + ], + } as unknown as AgentCoreProjectSpec; + + const resources: DeployedResourceState = { + knowledgeBases: { + 'product-docs': { + knowledgeBaseId: 'KB1', + knowledgeBaseArn: 'arn:aws:bedrock:us-west-2:0:knowledge-base/KB1', + dataSources: [{ dataSourceId: 'DS1', uri: 's3://b/d/' }], + }, + }, + }; + + const result = computeResourceStatuses(project, resources); + const kbEntry = result.find(r => r.resourceType === 'knowledge-base' && r.name === 'product-docs'); + + expect(kbEntry).toBeDefined(); + expect(kbEntry!.deploymentState).toBe('deployed'); + expect(kbEntry!.identifier).toBe('arn:aws:bedrock:us-west-2:0:knowledge-base/KB1'); + expect(kbEntry!.detail).toBe('1 data source'); + }); + + it('marks knowledge-base as local-only when not in deployed-state', () => { + const project = { + ...baseProject, + knowledgeBases: [ + { + type: 'AgentCoreKnowledgeBase', + name: 'fresh-kb', + dataSources: [ + { type: 'S3', uri: 's3://b/a/' }, + { type: 'S3', uri: 's3://b/b/' }, + ], + }, + ], + } as unknown as AgentCoreProjectSpec; + + const result = computeResourceStatuses(project, undefined); + const kbEntry = result.find(r => r.resourceType === 'knowledge-base' && r.name === 'fresh-kb'); + + expect(kbEntry).toBeDefined(); + expect(kbEntry!.deploymentState).toBe('local-only'); + expect(kbEntry!.detail).toBe('2 data sources'); + }); + + it('marks knowledge-base as pending-removal when in deployed-state but not local', () => { + const project = baseProject; + const resources: DeployedResourceState = { + knowledgeBases: { + 'orphan-kb': { + knowledgeBaseId: 'KBOLD', + knowledgeBaseArn: 'arn:aws:bedrock:us-west-2:0:knowledge-base/KBOLD', + dataSources: [], + }, + }, + }; + + const result = computeResourceStatuses(project, resources); + const kbEntry = result.find(r => r.resourceType === 'knowledge-base' && r.name === 'orphan-kb'); + + expect(kbEntry).toBeDefined(); + expect(kbEntry!.deploymentState).toBe('pending-removal'); + }); + + it('surfaces gateway wiring on KB detail when a connector target references it', () => { + const project = { + ...baseProject, + agentCoreGateways: [ + { + name: 'main-gw', + targets: [ + { + name: 'docs', + targetType: 'connector', + connectorId: 'bedrock-knowledge-bases', + knowledgeBaseId: 'docs', + }, + ], + }, + ], + knowledgeBases: [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://b/d/' }], + }, + ], + } as unknown as AgentCoreProjectSpec; + + const result = computeResourceStatuses(project, undefined); + const kbEntry = result.find(r => r.resourceType === 'knowledge-base' && r.name === 'docs'); + expect(kbEntry?.detail).toBe('1 data source → gw:main-gw'); + }); + + it('annotates gateway detail with retrieve-target count', () => { + const project = { + ...baseProject, + agentCoreGateways: [ + { + name: 'main-gw', + targets: [ + { name: 't1', targetType: 'mcpServer' }, + { name: 'docs', targetType: 'connector', connectorId: 'bedrock-knowledge-bases', knowledgeBaseId: 'docs' }, + ], + }, + ], + } as unknown as AgentCoreProjectSpec; + + const result = computeResourceStatuses(project, undefined); + const gwEntry = result.find(r => r.resourceType === 'gateway' && r.name === 'main-gw'); + expect(gwEntry?.detail).toBe('2 targets (1 retrieve)'); + }); + + it('annotates gateway detail with both retrieve count and agentic fan-out', () => { + const project = { + ...baseProject, + agentCoreGateways: [ + { + name: 'main-gw', + targets: [ + { name: 'docs', targetType: 'connector', connectorId: 'bedrock-knowledge-bases', knowledgeBaseId: 'docs' }, + { name: 'hr', targetType: 'connector', connectorId: 'bedrock-knowledge-bases', knowledgeBaseId: 'hr' }, + { + name: 'main-gw-agentic', + targetType: 'connector', + connectorId: 'bedrock-agentic-retrieve', + knowledgeBaseIds: ['docs', 'hr'], + }, + ], + }, + ], + } as unknown as AgentCoreProjectSpec; + + const result = computeResourceStatuses(project, undefined); + const gwEntry = result.find(r => r.resourceType === 'gateway' && r.name === 'main-gw'); + expect(gwEntry?.detail).toBe('3 targets (2 retrieve, agentic ×2)'); + }); + + it('KB detail surfaces wiring from agentic-retrieve fan-out target', () => { + const project = { + ...baseProject, + agentCoreGateways: [ + { + name: 'main-gw', + targets: [ + { + name: 'main-gw-agentic', + targetType: 'connector', + connectorId: 'bedrock-agentic-retrieve', + knowledgeBaseIds: ['docs'], + }, + ], + }, + ], + knowledgeBases: [ + { type: 'AgentCoreKnowledgeBase', name: 'docs', dataSources: [{ type: 'S3', uri: 's3://b/d/' }] }, + ], + } as unknown as AgentCoreProjectSpec; + + const result = computeResourceStatuses(project, undefined); + const kbEntry = result.find(r => r.resourceType === 'knowledge-base' && r.name === 'docs'); + expect(kbEntry?.detail).toBe('1 data source → gw:main-gw'); + }); }); describe('handleProjectStatus — live enrichment', () => { @@ -732,6 +963,224 @@ describe('handleProjectStatus — live enrichment', () => { }); }); +describe('handleProjectStatus — knowledge base enrichment', () => { + beforeEach(() => { + mockGetKnowledgeBase.mockReset(); + mockGetLatestIngestionJob.mockReset(); + loggedLines.length = 0; + }); + + afterEach(() => vi.clearAllMocks()); + + function makeKbContext(): StatusContext { + return { + project: { + ...baseProject, + agentCoreGateways: [ + { + name: 'main-gw', + targets: [ + { + name: 'docs', + targetType: 'connector', + connectorId: 'bedrock-knowledge-bases', + knowledgeBaseId: 'product-docs', + }, + ], + }, + ], + knowledgeBases: [ + { + type: 'AgentCoreKnowledgeBase', + name: 'product-docs', + dataSources: [ + { type: 'S3', uri: 's3://bucket/docs/' }, + { type: 'S3', uri: 's3://bucket/specs/' }, + ], + }, + ], + } as unknown as AgentCoreProjectSpec, + awsTargets: [{ name: 'dev', region: 'us-east-1', account: '123456789' }], + deployedState: { + targets: { + dev: { + resources: { + knowledgeBases: { + 'product-docs': { + knowledgeBaseId: 'KB1', + knowledgeBaseArn: 'arn:aws:bedrock:us-east-1:123456789:knowledge-base/KB1', + dataSources: [ + { dataSourceId: 'DS1', uri: 's3://bucket/docs/' }, + { dataSourceId: 'DS2', uri: 's3://bucket/specs/' }, + ], + }, + }, + }, + }, + }, + }, + } as unknown as StatusContext; + } + + it('fetches the latest ingestion job for every data source and renders all of them', async () => { + mockGetKnowledgeBase.mockResolvedValue({ knowledgeBaseId: 'KB1', status: 'ACTIVE' }); + mockGetLatestIngestionJob.mockImplementation(({ dataSourceId }: { dataSourceId: string }) => { + if (dataSourceId === 'DS1') { + return { + status: 'COMPLETE', + startedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:05:00Z'), + statistics: { + numberOfDocumentsScanned: 10, + numberOfNewDocumentsIndexed: 8, + numberOfModifiedDocumentsIndexed: 1, + numberOfDocumentsFailed: 0, + numberOfDocumentsDeleted: 0, + }, + }; + } + return { + status: 'COMPLETE', + startedAt: new Date('2026-01-02T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:03:00Z'), + statistics: { + numberOfDocumentsScanned: 5, + numberOfNewDocumentsIndexed: 5, + numberOfModifiedDocumentsIndexed: 0, + numberOfDocumentsFailed: 0, + numberOfDocumentsDeleted: 0, + }, + }; + }); + + // Drill into the named KB so the full per-DS block is rendered (the default + // view is now a one-line summary; see the dedicated summary/detail tests). + const result = await handleProjectStatus(makeKbContext(), { knowledgeBaseName: 'product-docs' }); + + assert(result.success); + + // A job was fetched for EACH data source, not just the first. + expect(mockGetLatestIngestionJob).toHaveBeenCalledTimes(2); + expect(mockGetLatestIngestionJob).toHaveBeenCalledWith({ + region: 'us-east-1', + knowledgeBaseId: 'KB1', + dataSourceId: 'DS1', + }); + expect(mockGetLatestIngestionJob).toHaveBeenCalledWith({ + region: 'us-east-1', + knowledgeBaseId: 'KB1', + dataSourceId: 'DS2', + }); + + // The rich block, logged line-by-line, includes BOTH data source URIs and + // their per-DS document counts plus the gateway wiring. + const block = loggedLines.join('\n'); + expect(block).toContain('s3://bucket/docs/'); + expect(block).toContain('s3://bucket/specs/'); + expect(block).toContain('10 scanned, 8 new indexed'); + expect(block).toContain('5 scanned, 5 new indexed'); + expect(block).toContain('main-gw'); + + // The structured detail stays a concise one-liner for TUI/JSON consumers. + const kbEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'knowledge-base' && r.name === 'product-docs' + ); + expect(kbEntry).toBeDefined(); + expect(kbEntry!.detail).toContain('Status: ACTIVE'); + expect(kbEntry!.detail).not.toContain('\n'); + }); + + it('renders a one-line summary (not the full per-DS block) when no knowledgeBaseName is given', async () => { + mockGetKnowledgeBase.mockResolvedValue({ knowledgeBaseId: 'KB1', status: 'ACTIVE' }); + mockGetLatestIngestionJob.mockResolvedValue({ + status: 'COMPLETE', + startedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:05:00Z'), + statistics: { + numberOfDocumentsScanned: 10, + numberOfNewDocumentsIndexed: 8, + numberOfModifiedDocumentsIndexed: 0, + numberOfDocumentsFailed: 0, + numberOfDocumentsDeleted: 0, + }, + }); + + const result = await handleProjectStatus(makeKbContext()); + assert(result.success); + + const block = loggedLines.join('\n'); + // The summary rollup line is present (name + state + counts). + expect(block).toContain('product-docs: ✓ Ready'); + expect(block).toContain('2 data sources'); + expect(block).toContain('16 indexed'); + // The full multi-line block is NOT rendered by default. + expect(block).not.toContain('Documents:'); + expect(block).not.toContain('s3://bucket/docs/'); + }); + + it('renders the full per-DS block when knowledgeBaseName matches', async () => { + mockGetKnowledgeBase.mockResolvedValue({ knowledgeBaseId: 'KB1', status: 'ACTIVE' }); + mockGetLatestIngestionJob.mockResolvedValue({ + status: 'COMPLETE', + startedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:05:00Z'), + statistics: { + numberOfDocumentsScanned: 10, + numberOfNewDocumentsIndexed: 8, + numberOfModifiedDocumentsIndexed: 0, + numberOfDocumentsFailed: 0, + numberOfDocumentsDeleted: 0, + }, + }); + + const result = await handleProjectStatus(makeKbContext(), { knowledgeBaseName: 'product-docs' }); + assert(result.success); + + const block = loggedLines.join('\n'); + // The full multi-line block IS rendered for the named KB. + expect(block).toContain('Documents:'); + expect(block).toContain('s3://bucket/docs/'); + expect(block).toContain('s3://bucket/specs/'); + }); + + it('marks data sources with no ingestion job as never run', async () => { + mockGetKnowledgeBase.mockResolvedValue({ knowledgeBaseId: 'KB1', status: 'ACTIVE' }); + mockGetLatestIngestionJob.mockResolvedValue(null); + + // "never run" appears only in the full drill-down block. + const result = await handleProjectStatus(makeKbContext(), { knowledgeBaseName: 'product-docs' }); + + assert(result.success); + expect(mockGetLatestIngestionJob).toHaveBeenCalledTimes(2); + expect(loggedLines.join('\n')).toContain('Ingestion: never run'); + }); + + it('flags KB as out of sync when the live KB is not found', async () => { + mockGetKnowledgeBase.mockResolvedValue(null); + + const result = await handleProjectStatus(makeKbContext()); + + assert(result.success); + const kbEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'knowledge-base' && r.name === 'product-docs' + ); + expect(kbEntry!.detail).toContain('out of sync'); + expect(mockGetLatestIngestionJob).not.toHaveBeenCalled(); + }); + + it('sets error on KB when getKnowledgeBase throws', async () => { + mockGetKnowledgeBase.mockRejectedValue(new Error('AccessDenied')); + + const result = await handleProjectStatus(makeKbContext()); + + assert(result.success); + const kbEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'knowledge-base' && r.name === 'product-docs' + ); + expect(kbEntry!.error).toBe('AccessDenied'); + }); +}); + describe('buildRuntimeInvocationUrl', () => { it('constructs the correct invocation URL with encoded ARN', () => { const url = buildRuntimeInvocationUrl( diff --git a/src/cli/commands/status/__tests__/format-knowledge-base.test.ts b/src/cli/commands/status/__tests__/format-knowledge-base.test.ts new file mode 100644 index 000000000..43fb51b16 --- /dev/null +++ b/src/cli/commands/status/__tests__/format-knowledge-base.test.ts @@ -0,0 +1,117 @@ +import { + type KbStatusDetail, + formatKnowledgeBaseDetail, + formatKnowledgeBaseSummaryLine, +} from '../format-knowledge-base'; +import { describe, expect, it } from 'vitest'; + +const base: KbStatusDetail = { + name: 'product-docs', + knowledgeBaseId: 'KB-ABC', + status: 'ACTIVE', + gatewayNames: ['main-gw'], + dataSources: [ + { + uri: 's3://bucket/docs/', + dataSourceId: 'DS-1', + ingestion: { + status: 'COMPLETE', + startedAt: '2026-02-20T23:03:36Z', + completedAt: '2026-02-20T23:15:42Z', + scanned: 141, + indexed: 138, + modified: 0, + failed: 3, + deleted: 0, + }, + }, + ], +}; + +describe('formatKnowledgeBaseDetail', () => { + it('renders a multi-line block with per-DS state and tool line', () => { + const text = formatKnowledgeBaseDetail(base).join('\n'); + expect(text).toContain('KB-ABC'); + expect(text).toContain('s3://bucket/docs/'); + expect(text).toContain('138 new indexed'); + expect(text).toContain('Tools:'); + expect(text).toContain('retrieve'); + }); + + it('renders troubleshooting hints on ingestion failure', () => { + const ds0 = base.dataSources[0]!; + const failed: KbStatusDetail = { + ...base, + dataSources: [{ ...ds0, ingestion: { ...ds0.ingestion, status: 'FAILED' } }], + }; + const text = formatKnowledgeBaseDetail(failed).join('\n'); + expect(text).toContain('Next steps'); + expect(text).toMatch(/50MB|file format|s3:GetObject/i); + }); + + it('omits the tool line when not wired to any gateway', () => { + const standalone = { ...base, gatewayNames: [] }; + const text = formatKnowledgeBaseDetail(standalone).join('\n'); + expect(text).not.toContain('Tools:'); + }); + + it('shows "never run" when a data source has no ingestion', () => { + const noIngest = { ...base, dataSources: [{ uri: 's3://b/', dataSourceId: 'DS-9' }] }; + const text = formatKnowledgeBaseDetail(noIngest).join('\n'); + expect(text).toContain('never run'); + }); + + it('lists every data source for a multi-DS KB', () => { + const multi = { + ...base, + dataSources: [ + { uri: 's3://b/a/', dataSourceId: 'DS-1', ingestion: { status: 'COMPLETE', scanned: 1, indexed: 1 } }, + { uri: 's3://b/c/', dataSourceId: 'DS-2', ingestion: { status: 'IN_PROGRESS', scanned: 5, indexed: 0 } }, + ], + }; + const text = formatKnowledgeBaseDetail(multi).join('\n'); + expect(text).toContain('s3://b/a/'); + expect(text).toContain('s3://b/c/'); + }); +}); + +describe('formatKnowledgeBaseSummaryLine', () => { + it('renders name, Ready state, count, and indexed total for an ACTIVE KB with complete ingestion', () => { + const line = formatKnowledgeBaseSummaryLine(base); + expect(line).toContain('product-docs'); + expect(line).toContain('Ready'); + expect(line).toContain('1 data source'); + expect(line).toContain('138 indexed'); + }); + + it('shows Failed when any data source ingestion failed', () => { + const ds0 = base.dataSources[0]!; + const failed: KbStatusDetail = { + ...base, + dataSources: [{ ...ds0, ingestion: { ...ds0.ingestion, status: 'FAILED' } }], + }; + expect(formatKnowledgeBaseSummaryLine(failed)).toContain('Failed'); + }); + + it('shows Ingesting when a data source is in progress', () => { + const ds0 = base.dataSources[0]!; + const ingesting: KbStatusDetail = { + ...base, + dataSources: [{ ...ds0, ingestion: { ...ds0.ingestion, status: 'IN_PROGRESS' } }], + }; + expect(formatKnowledgeBaseSummaryLine(ingesting)).toContain('Ingesting'); + }); + + it('sums indexed across data sources for a multi-DS KB', () => { + const multi: KbStatusDetail = { + ...base, + dataSources: [ + { uri: 's3://b/a/', dataSourceId: 'DS-1', ingestion: { status: 'COMPLETE', indexed: 100 } }, + { uri: 's3://b/c/', dataSourceId: 'DS-2', ingestion: { status: 'COMPLETE', indexed: 50 } }, + ], + }; + const line = formatKnowledgeBaseSummaryLine(multi); + expect(line).toContain('2 data sources'); + expect(line).toContain('150 indexed'); + }); +}); diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 057aa272b..d2337cb1f 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -4,12 +4,18 @@ import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedResourceState, import { getAgentRuntimeStatus } from '../../aws'; import { getEvaluator, getOnlineEvaluationConfig } from '../../aws/agentcore-control'; import { getPaymentManager } from '../../aws/agentcore-payments'; -import { dnsSuffix } from '../../aws/partition'; +import { getKnowledgeBase, getLatestIngestionJob } from '../../aws/bedrock-agent'; import { getErrorMessage } from '../../errors'; -import { isPreviewEnabled } from '../../feature-flags'; +import { isGatedFeaturesEnabled, isPreviewEnabled } from '../../feature-flags'; import { ExecLogger } from '../../logging'; import type { ResourceDeploymentState } from './constants'; import { buildRuntimeInvocationUrl } from './constants'; +import { + type KbDataSourceDetail, + type KbStatusDetail, + formatKnowledgeBaseDetail, + formatKnowledgeBaseSummaryLine, +} from './format-knowledge-base'; export type { ResourceDeploymentState }; @@ -24,10 +30,10 @@ export interface ResourceStatusEntry { | 'policy-engine' | 'policy' | 'config-bundle' - | 'ab-test' | 'dataset' | 'harness' | 'runtime-endpoint' + | 'knowledge-base' | 'payment'; name: string; deploymentState: ResourceDeploymentState; @@ -126,30 +132,6 @@ function diffResourceSet({ return entries; } -/** - * Build the full gateway invocation URL for an AB test. - * Appends the runtime target name and /invocations path to the gateway base URL. - */ -function buildGatewayInvocationUrl( - gwState: { gatewayId: string; gatewayArn: string; gatewayUrl?: string }, - gwName: string, - project: AgentCoreProjectSpec -): string | undefined { - // Use stored URL or derive from ARN: arn:aws:bedrock-agentcore:{region}:{account}:gateway/{id} - const baseUrl = - gwState.gatewayUrl ?? - (() => { - const region = gwState.gatewayArn.split(':')[3]; - return region - ? `https://${gwState.gatewayId}.gateway.bedrock-agentcore.${region}.${dnsSuffix(region)}` - : undefined; - })(); - if (!baseUrl) return undefined; - const gwSpec = (project.httpGateways ?? []).find(gw => gw.name === gwName); - if (!gwSpec) return baseUrl; - return `${baseUrl}/${gwSpec.runtimeRef}/invocations`; -} - export function computeResourceStatuses( project: AgentCoreProjectSpec, resources: DeployedResourceState | undefined @@ -183,11 +165,25 @@ export function computeResourceStatuses( const gateways = diffResourceSet({ resourceType: 'gateway', localItems: project.agentCoreGateways ?? [], - deployedRecord: resources?.mcp?.gateways ?? {}, + deployedRecord: { ...(resources?.mcp?.gateways ?? {}), ...(resources?.gateways ?? {}) }, getIdentifier: deployed => deployed.gatewayId, getLocalDetail: item => { - const count = item.targets?.length ?? 0; - return count > 0 ? `${count} target${count !== 1 ? 's' : ''}` : undefined; + const targets = item.targets ?? []; + if (targets.length === 0) return undefined; + const retrieveCount = targets.filter( + t => t.targetType === 'connector' && t.connectorId === 'bedrock-knowledge-bases' + ).length; + const agentic = targets.find(t => t.targetType === 'connector' && t.connectorId === 'bedrock-agentic-retrieve'); + const webSearchCount = targets.filter(t => t.targetType === 'webSearch').length; + const base = `${targets.length} target${targets.length !== 1 ? 's' : ''}`; + const parts: string[] = []; + if (retrieveCount > 0) parts.push(`${retrieveCount} retrieve`); + if (agentic) { + const fanOut = agentic.knowledgeBaseIds?.length ?? 0; + parts.push(`agentic ×${fanOut}`); + } + if (webSearchCount > 0) parts.push(`${webSearchCount} web-search`); + return parts.length > 0 ? `${base} (${parts.join(', ')})` : base; }, }); @@ -205,7 +201,7 @@ export function computeResourceStatuses( deployedRecord: resources?.onlineEvalConfigs ?? {}, getIdentifier: deployed => deployed.onlineEvaluationConfigArn, getLocalDetail: item => - `${item.evaluators.length} evaluator${item.evaluators.length !== 1 ? 's' : ''}, ${item.samplingRate}% sampling`, + `${(item.evaluators ?? []).length} evaluator${(item.evaluators ?? []).length !== 1 ? 's' : ''}, ${item.samplingRate}% sampling`, }); const policyEngines = diffResourceSet({ @@ -252,29 +248,43 @@ export function computeResourceStatuses( getLocalDetail: item => item.schemaType, }); - const abTests = diffResourceSet({ - resourceType: 'ab-test', - localItems: project.abTests ?? [], - deployedRecord: resources?.abTests ?? {}, - getIdentifier: deployed => deployed.abTestArn, - getLocalDetail: item => item.description, - }); - - // Enrich deployed AB tests with gateway invocation URL - const httpGatewayState = resources?.httpGateways ?? {}; - for (const entry of abTests) { - if (entry.deploymentState !== 'deployed') continue; - const testSpec = (project.abTests ?? []).find(t => t.name === entry.name); - if (!testSpec) continue; - const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(testSpec.gatewayRef); - const gwName = gwMatch?.[1]; - if (!gwName) continue; - const gwState = httpGatewayState[gwName]; - if (!gwState) continue; - const url = buildGatewayInvocationUrl(gwState, gwName, project); - if (url) entry.invocationUrl = url; + // Reverse-index: KB name -> list of gateways with a connector target referencing it. + // Walks both knowledgeBaseId (single-KB Retrieve) and knowledgeBaseIds[] + // (agentic-retrieve fan-out) so a KB shows its wiring no matter which + // connector kind references it. + const kbToGateways = new Map>(); + const recordKbWiring = (kbRef: string, gatewayName: string): void => { + const set = kbToGateways.get(kbRef) ?? new Set(); + set.add(gatewayName); + kbToGateways.set(kbRef, set); + }; + for (const gw of project.agentCoreGateways ?? []) { + for (const t of gw.targets ?? []) { + if (t.targetType !== 'connector') continue; + if (t.knowledgeBaseId) recordKbWiring(t.knowledgeBaseId, gw.name); + for (const ref of t.knowledgeBaseIds ?? []) recordKbWiring(ref, gw.name); + } } + const knowledgeBases = diffResourceSet({ + resourceType: 'knowledge-base', + localItems: project.knowledgeBases ?? [], + deployedRecord: resources?.knowledgeBases ?? {}, + getIdentifier: deployed => deployed.knowledgeBaseArn, + getLocalDetail: item => { + const dsPart = `${item.dataSources.length} data source${item.dataSources.length === 1 ? '' : 's'}`; + // Wave 2: connector target binds the KB to a gateway. Project-owned + // KBs are stored by name on the connector target; external KBs are + // stored as a literal id (which won't match a knowledgeBases[] entry). + // Either way, we look up by name here — any extra hit (the spec's own + // gateway field) is fine to fold in. + const wiredGateways = new Set(kbToGateways.get(item.name) ?? []); + if (item.gateway) wiredGateways.add(item.gateway); + if (wiredGateways.size === 0) return dsPart; + return `${dsPart} → gw:${[...wiredGateways].join(',')}`; + }, + }); + // Flatten runtime endpoints for diffing against deployed state const localEndpoints: { name: string; agentName: string; version: number; description?: string }[] = []; for (const runtime of project.runtimes) { @@ -310,6 +320,15 @@ export function computeResourceStatuses( }) : []; + // Config version (gated): the harness Version is service-incremented and only known from deployed + // state, so enrich each entry's detail post-pass rather than via getLocalDetail (local spec has none). + if (isGatedFeaturesEnabled()) { + for (const entry of harnesses) { + const version = resources?.harnesses?.[entry.name]?.harnessVersion; + if (version !== undefined) entry.detail = entry.detail ? `${entry.detail} v${version}` : `v${version}`; + } + } + const payments = diffResourceSet({ resourceType: 'payment', localItems: project.payments ?? [], @@ -330,8 +349,8 @@ export function computeResourceStatuses( ...policyEngines, ...policies, ...datasets, + ...knowledgeBases, ...configBundles, - ...abTests, ...harnesses, ...payments, ]; @@ -339,7 +358,7 @@ export function computeResourceStatuses( export async function handleProjectStatus( context: StatusContext, - options: { targetName?: string } = {} + options: { targetName?: string; knowledgeBaseName?: string } = {} ): Promise { const logger = new ExecLogger({ command: 'status' }); const { project, deployedState, awsTargets } = context; @@ -508,6 +527,133 @@ export async function handleProjectStatus( logger.endStep(hasOnlineEvalErrors ? 'error' : 'success'); } + // Enrich deployed knowledge bases with live KB status + latest ingestion job stats + const kbStates = targetResources?.knowledgeBases ?? {}; + const deployedKbs = resources.filter( + e => e.resourceType === 'knowledge-base' && e.deploymentState === 'deployed' && kbStates[e.name] + ); + + if (deployedKbs.length > 0) { + logger.startStep(`Fetch knowledge base status (${deployedKbs.length} KB${deployedKbs.length !== 1 ? 's' : ''})`); + + // Reverse-index: KB spec name -> gateways whose connector targets + // reference it. Project-owned KBs are stored by *name* on connector + // targets (single-KB Retrieve on `knowledgeBaseId`, agentic-retrieve + // fan-out on `knowledgeBaseIds[]`), so we key by the spec name + // (entry.name) below. + const kbNameToGateways = new Map>(); + const recordKbWiring = (kbRef: string, gatewayName: string): void => { + const set = kbNameToGateways.get(kbRef) ?? new Set(); + set.add(gatewayName); + kbNameToGateways.set(kbRef, set); + }; + for (const gw of project.agentCoreGateways ?? []) { + for (const t of gw.targets ?? []) { + if (t.targetType !== 'connector') continue; + if (t.knowledgeBaseId) recordKbWiring(t.knowledgeBaseId, gw.name); + for (const ref of t.knowledgeBaseIds ?? []) recordKbWiring(ref, gw.name); + } + } + + await Promise.all( + resources.map(async (entry, i) => { + if (entry.resourceType !== 'knowledge-base' || entry.deploymentState !== 'deployed') return; + + const kbState = kbStates[entry.name]; + if (!kbState) return; + + try { + const live = await getKnowledgeBase({ + region: targetConfig.region, + knowledgeBaseId: kbState.knowledgeBaseId, + }); + if (!live) { + const outOfSync = 'out of sync (KB deleted out of band)'; + const detail = entry.detail ? `${entry.detail} — ${outOfSync}` : outOfSync; + resources[i] = { ...entry, detail }; + logger.log(` ${entry.name}: KB ${kbState.knowledgeBaseId} not found`, 'error'); + return; + } + + // Fetch the latest ingestion job for EVERY data source, in parallel, + // and map each into a per-DS detail for the rich formatter. + const dataSources: KbDataSourceDetail[] = await Promise.all( + kbState.dataSources.map(async ds => { + const job = await getLatestIngestionJob({ + region: targetConfig.region, + knowledgeBaseId: kbState.knowledgeBaseId, + dataSourceId: ds.dataSourceId, + }); + if (!job) { + return { uri: ds.uri, dataSourceId: ds.dataSourceId }; + } + const stats = job.statistics ?? {}; + // 'COMPLETE' is the SDK's terminal success status for ingestion + // jobs; treat it as completed so the formatter shows a finish time. + const succeeded = job.status === 'COMPLETE'; + return { + uri: ds.uri, + dataSourceId: ds.dataSourceId, + ingestion: { + status: job.status, + startedAt: job.startedAt?.toISOString(), + updatedAt: job.updatedAt?.toISOString(), + completedAt: succeeded ? job.updatedAt?.toISOString() : undefined, + scanned: stats.numberOfDocumentsScanned, + indexed: stats.numberOfNewDocumentsIndexed, + modified: stats.numberOfModifiedDocumentsIndexed, + failed: stats.numberOfDocumentsFailed, + deleted: stats.numberOfDocumentsDeleted, + }, + }; + }) + ); + + const gatewayNames = [...(kbNameToGateways.get(entry.name) ?? new Set())]; + + const kbDetail: KbStatusDetail = { + name: entry.name, + knowledgeBaseId: kbState.knowledgeBaseId, + status: live.status, + gatewayNames, + dataSources, + }; + + // Render branch: with --name (knowledgeBaseName) we drill into the + // full multi-line block for the matched KB only; without it we emit a + // single summary rollup line per KB so `agentcore status` stays + // uncluttered when several KBs are deployed. Either way the structured + // `detail` below stays a concise one-liner because it is both rendered + // inline in the TUI and serialized in `--json` mode. + if (options.knowledgeBaseName) { + if (entry.name === options.knowledgeBaseName) { + for (const line of formatKnowledgeBaseDetail(kbDetail)) { + logger.log(line); + } + } + } else { + logger.log(formatKnowledgeBaseSummaryLine(kbDetail)); + } + + const firstWithJob = dataSources.find(ds => ds.ingestion); + const ingestionSummary = firstWithJob?.ingestion?.status + ? `Ingestion: ${firstWithJob.ingestion.status}` + : 'Ingestion: never run'; + const enriched = `Status: ${live.status ?? 'UNKNOWN'} — ${ingestionSummary}`; + const detail = entry.detail ? `${entry.detail} — ${enriched}` : enriched; + resources[i] = { ...entry, detail }; + } catch (error) { + const errorMsg = getErrorMessage(error); + resources[i] = { ...entry, error: errorMsg }; + logger.log(` ${entry.name}: ERROR - ${errorMsg}`, 'error'); + } + }) + ); + + const hasKbErrors = resources.some(r => r.resourceType === 'knowledge-base' && r.error); + logger.endStep(hasKbErrors ? 'error' : 'success'); + } + // Enrich deployed payment managers with live status const paymentStates = targetResources?.payments ?? {}; const deployedPayments = resources.filter( diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index b60276219..1d93e8162 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -25,8 +25,8 @@ const VALID_RESOURCE_TYPES = [ 'policy-engine', 'policy', 'config-bundle', - 'ab-test', 'dataset', + 'knowledge-base', ...(isPreviewEnabled() ? (['harness'] as const) : []), ] as const; const VALID_STATES = ['deployed', 'local-only', 'pending-removal'] as const; @@ -37,12 +37,13 @@ interface StatusCliOptions { type?: string; state?: string; runtime?: string; + name?: string; json?: boolean; } function filterResources( resources: ResourceStatusEntry[], - options: { type?: string; state?: string; runtime?: string } + options: { type?: string; state?: string; runtime?: string; name?: string } ): ResourceStatusEntry[] { let filtered = resources; @@ -58,6 +59,13 @@ function filterResources( filtered = filtered.filter(r => r.resourceType !== 'agent' || r.name === options.runtime); } + // --name drills into a single resource by name. It's meant for knowledge-base + // (paired with --type knowledge-base), but a bare --name still narrows the list + // by name across all types, which is sensible behaviour. + if (options.name) { + filtered = filtered.filter(r => r.name === options.name); + } + return filtered; } @@ -71,6 +79,7 @@ export const registerStatus = (program: Command) => { .option('--type ', `Filter by resource type (${VALID_RESOURCE_TYPES.join(', ')})`) .option('--state ', 'Filter by deployment state (deployed, local-only, pending-removal)') .option('--runtime ', 'Filter to a specific runtime') + .option('--name ', 'Show details for a single resource by name (knowledge-base)') .option('--json', 'Output as JSON') .action(async (cliOptions: StatusCliOptions) => { requireProject(); @@ -139,7 +148,12 @@ export const registerStatus = (program: Command) => { // Default path: show all resource types with deployment state const result = await withCommandRunTelemetry('status', telemetryAttrs, async () => { const context = await loadStatusConfig(); - return handleProjectStatus(context, { targetName: cliOptions.target }); + // --name drives the KB drill-down (full block) vs the default summary + // line. Scope it to KB filtering: only thread it when the user hasn't + // narrowed to a different resource type. + const knowledgeBaseName = + cliOptions.name && (!cliOptions.type || cliOptions.type === 'knowledge-base') ? cliOptions.name : undefined; + return handleProjectStatus(context, { targetName: cliOptions.target, knowledgeBaseName }); }); if (!result.success) { @@ -168,8 +182,8 @@ export const registerStatus = (program: Command) => { const policyEngines = filtered.filter(r => r.resourceType === 'policy-engine'); const policies = filtered.filter(r => r.resourceType === 'policy'); const configBundles = filtered.filter(r => r.resourceType === 'config-bundle'); - const abTests = filtered.filter(r => r.resourceType === 'ab-test'); const datasets = filtered.filter(r => r.resourceType === 'dataset'); + const knowledgeBases = filtered.filter(r => r.resourceType === 'knowledge-base'); const harnesses = filtered.filter(r => r.resourceType === 'harness'); const payments = filtered.filter(r => r.resourceType === 'payment'); // TODO: Add http-gateway resource type when diffResourceSet for HTTP gateways is added to action.ts @@ -314,22 +328,6 @@ export const registerStatus = (program: Command) => { )} - {abTests.length > 0 && ( - - AB Tests - {abTests.map(entry => ( - - - {entry.invocationUrl && ( - - {' '}Invocation URL: {entry.invocationUrl} - - )} - - ))} - - )} - {datasets.length > 0 && ( Datasets @@ -381,6 +379,15 @@ export const registerStatus = (program: Command) => { )} + {knowledgeBases.length > 0 && ( + + Knowledge Bases + {knowledgeBases.map(entry => ( + + ))} + + )} + {payments.length > 0 && ( Payments diff --git a/src/cli/commands/status/format-knowledge-base.ts b/src/cli/commands/status/format-knowledge-base.ts new file mode 100644 index 000000000..2a82842b3 --- /dev/null +++ b/src/cli/commands/status/format-knowledge-base.ts @@ -0,0 +1,94 @@ +export interface KbIngestionDetail { + status?: string; + startedAt?: string; + updatedAt?: string; + completedAt?: string; + scanned?: number; + indexed?: number; + modified?: number; + failed?: number; + deleted?: number; +} + +export interface KbDataSourceDetail { + uri: string; + dataSourceId: string; + ingestion?: KbIngestionDetail; +} + +export interface KbStatusDetail { + name: string; + knowledgeBaseId: string; + status?: string; + gatewayNames: string[]; + dataSources: KbDataSourceDetail[]; +} + +const FAILURE_HINTS = [ + 'Next steps:', + ' → Retry ingestion: agentcore run ingest --name ', + ' → Common causes:', + ' • Document format not supported (.txt, .md, .html, .pdf, .doc, .csv, .xls)', + ' • File exceeds 50MB size limit', + ' • S3 bucket permissions — ensure the KB role has s3:GetObject access', + ' • Data source credentials expired (Confluence, SharePoint, etc.)', +]; + +/** Render the rich, multi-line KB status block per the DevEx spec. */ +export function formatKnowledgeBaseDetail(kb: KbStatusDetail): string[] { + const lines: string[] = []; + lines.push(`Knowledge Base: ${kb.name}`); + lines.push( + ` Knowledge Base: ${kb.status === 'ACTIVE' ? '✓' : '⟳'} ${kb.status ?? 'UNKNOWN'} (${kb.knowledgeBaseId})` + ); + + let anyFailed = false; + lines.push(` Data Sources (${kb.dataSources.length}):`); + for (const ds of kb.dataSources) { + const ing = ds.ingestion; + const mark = ing?.status === 'FAILED' ? '✗' : ing?.status === 'COMPLETE' || ing?.status === 'SUCCEEDED' ? '✓' : '⟳'; + lines.push(` ${mark} ${ds.uri} (${ds.dataSourceId})`); + if (ing) { + if (ing.status === 'FAILED') anyFailed = true; + lines.push(` Ingestion: ${ing.status ?? 'UNKNOWN'}`); + if (ing.startedAt) lines.push(` Started: ${ing.startedAt}`); + if (ing.completedAt) lines.push(` Completed: ${ing.completedAt}`); + else if (ing.updatedAt) lines.push(` Updated: ${ing.updatedAt}`); + lines.push( + ` Documents: ${ing.scanned ?? 0} scanned, ${ing.indexed ?? 0} new indexed, ${ing.modified ?? 0} modified, ${ing.failed ?? 0} failed, ${ing.deleted ?? 0} deleted` + ); + } else { + lines.push(' Ingestion: never run'); + } + } + + if (kb.gatewayNames.length > 0) { + lines.push(` Gateways: ${kb.gatewayNames.join(', ')}`); + lines.push(' Tools: retrieve (available)'); + } + + if (anyFailed) { + lines.push(''); + lines.push(...FAILURE_HINTS.map(h => ` ${h}`)); + } + + return lines; +} + +/** One-line rollup for the summary view (no --name). */ +export function formatKnowledgeBaseSummaryLine(kb: KbStatusDetail): string { + const totalIndexed = kb.dataSources.reduce((n, ds) => n + (ds.ingestion?.indexed ?? 0), 0); + const anyFailed = kb.dataSources.some(ds => ds.ingestion?.status === 'FAILED'); + const ingesting = kb.dataSources.some( + ds => ds.ingestion && ['IN_PROGRESS', 'STARTING', 'SUBMITTED'].includes(ds.ingestion.status ?? '') + ); + const state = anyFailed + ? '✗ Failed' + : ingesting + ? '⟳ Ingesting' + : kb.status === 'ACTIVE' + ? '✓ Ready' + : (kb.status ?? 'Unknown'); + const dsCount = kb.dataSources.length; + return `${kb.name}: ${state} (${dsCount} data source${dsCount !== 1 ? 's' : ''}, ${totalIndexed} indexed)`; +} diff --git a/src/cli/commands/stop/command.tsx b/src/cli/commands/stop/command.tsx index 75f9d96cf..acaaa45ff 100644 --- a/src/cli/commands/stop/command.tsx +++ b/src/cli/commands/stop/command.tsx @@ -1,45 +1,58 @@ -import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; -import { COMMAND_DESCRIPTIONS } from '../../constants'; -import { getErrorMessage } from '../../errors'; -import { getRegion } from '../shared/region-utils'; +import { ConfigIO } from '../../../lib'; +import { createJobEngine } from '../../operations/jobs'; +import { runCliCommand } from '../../telemetry/cli-command-run'; +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; +import { requireProject } from '../../tui/guards'; import type { Command } from '@commander-js/extra-typings'; -import { Text, render } from 'ink'; -import React from 'react'; export const registerStop = (program: Command) => { const stopCmd = program.command('stop').description(COMMAND_DESCRIPTIONS.stop); stopCmd - .command('batch-evaluation') - .description('[preview] Stop a running batch evaluation') - .requiredOption('-i, --id ', 'Batch evaluation ID to stop') + .command('ab-test') + .description('Stop a running A/B test permanently') + .requiredOption('-i, --id ', 'A/B test ID to stop') .option('--region ', 'AWS region (auto-detected if omitted)') .option('--json', 'Output as JSON') - .action(async (cliOptions: { id: string; region?: string; json?: boolean }) => { - try { - const region = await getRegion(cliOptions.region); - - const result = await stopBatchEvaluation({ - region, - batchEvaluationId: cliOptions.id, - }); + .action((cliOptions: { id: string; region?: string; json?: boolean }) => { + requireProject(); + return runCliCommand('stop.job', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + const result = await engine.stop('ab-test', cliOptions.id); + if (!result.success) { + throw result.error; + } if (cliOptions.json) { - console.log(JSON.stringify({ success: true, ...result })); + console.log(JSON.stringify({ success: true, id: cliOptions.id })); } else { - console.log(`\nBatch evaluation stopped successfully`); - console.log(`ID: ${result.batchEvaluationId}`); - console.log(`Status: ${result.status}\n`); + console.log(`\n✓ A/B test ${cliOptions.id} stop requested.\n`); } + return { job_type: 'ab-test' }; + }); + }); + + stopCmd + .command('batch-evaluation') + .description('Stop a running batch evaluation') + .requiredOption('-i, --id ', 'Batch evaluation ID to stop') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--json', 'Output as JSON') + .action((cliOptions: { id: string; region?: string; json?: boolean }) => { + requireProject(); - process.exit(0); - } catch (error) { + return runCliCommand('stop.job', !!cliOptions.json, async () => { + const engine = createJobEngine(new ConfigIO()); + const result = await engine.stop('batch-evaluation', cliOptions.id); + if (!result.success) { + throw result.error; + } if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + console.log(JSON.stringify({ success: true, id: cliOptions.id })); } else { - render(Error: {getErrorMessage(error)}); + console.log(`\n✓ Batch evaluation ${cliOptions.id} stop requested.\n`); } - process.exit(1); - } + return { job_type: 'batch-evaluation' }; + }); }); }; diff --git a/src/cli/commands/stop/index.ts b/src/cli/commands/stop/index.ts index 1f1a5e1e2..3f55d16c9 100644 --- a/src/cli/commands/stop/index.ts +++ b/src/cli/commands/stop/index.ts @@ -1 +1 @@ -export { registerStop } from '../pause/command'; +export { registerStop } from './command'; diff --git a/src/cli/commands/validate/action.ts b/src/cli/commands/validate/action.ts index bcb0988d7..57ed7252b 100644 --- a/src/cli/commands/validate/action.ts +++ b/src/cli/commands/validate/action.ts @@ -10,6 +10,7 @@ import { readEnvFile, } from '../../../lib'; import type { Result } from '../../../lib/result'; +import { validateHarnessSpecs } from '../../operations/deploy/preflight'; import { computePaymentCredentialEnvVarNames, computeStripePrivyCredentialEnvVarNames, @@ -181,6 +182,17 @@ export async function handleValidate(options: ValidateOptions): Promise } } + // Validate each per-harness harness.json against HarnessSpecSchema. `validate` previously only + // checked agentcore.json, so a malformed harness spec (bad model provider, missing + // executionRoleArn, an out-of-CFN-bounds field) passed "validate" yet failed at deploy synth. + // Reuse the deploy-preflight helper so `validate` and `deploy` aggregate the SAME way (report + // every broken harness at once, not one-per-rerun). + try { + await validateHarnessSpecs(projectSpec, configRoot); + } catch (err) { + return { success: false, error: err instanceof Error ? err : new Error(String(err)) }; + } + return { success: true }; } diff --git a/src/cli/commands/view/JobDetailScreen.tsx b/src/cli/commands/view/JobDetailScreen.tsx new file mode 100644 index 000000000..444814f5f --- /dev/null +++ b/src/cli/commands/view/JobDetailScreen.tsx @@ -0,0 +1,109 @@ +import { ConfigIO } from '../../../lib'; +import { validateAwsCredentials } from '../../aws/account'; +import { getErrorMessage } from '../../errors'; +import { createJobEngine } from '../../operations/jobs'; +import type { JobRecord, JobType } from '../../operations/jobs'; +import { ErrorPrompt, Screen } from '../../tui/components'; +import { ABTestDetailView, BatchEvalDetailView, RecommendationDetailView } from '../../tui/screens/job-detail'; +import { Text } from 'ink'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +interface JobDetailScreenProps { + type: JobType; + id: string; + onExit: () => void; +} + +type State = { name: 'loading' } | { name: 'error'; message: string } | { name: 'loaded'; record: JobRecord }; + +export function JobDetailScreen({ type, id, onExit }: JobDetailScreenProps) { + const engine = useMemo(() => createJobEngine(new ConfigIO()), []); + const [state, setState] = useState({ name: 'loading' }); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + await validateAwsCredentials(); + } catch (err) { + if (!cancelled) setState({ name: 'error', message: `AWS credentials required: ${getErrorMessage(err)}` }); + return; + } + try { + const record = await engine.get(type, id); + if (!record) { + if (!cancelled) setState({ name: 'error', message: `Job "${id}" not found.` }); + return; + } + if (!cancelled) setState({ name: 'loaded', record }); + } catch (err) { + if (!cancelled) setState({ name: 'error', message: getErrorMessage(err) }); + } + })(); + return () => { + cancelled = true; + }; + }, [engine, type, id]); + + const handleUpdate = useCallback((updated: JobRecord) => { + setState({ name: 'loaded', record: updated }); + }, []); + + if (state.name === 'loading') { + return ( + + Loading job {id}... + + ); + } + + if (state.name === 'error') { + return ; + } + + const { record } = state; + + if (record.type === 'ab-test') { + return ( + + + + ); + } + if (record.type === 'batch-evaluation') { + return ( + + + + ); + } + if (record.type === 'recommendation') { + return ( + + + + ); + } + return ( + + ); +} diff --git a/src/cli/commands/view/command.tsx b/src/cli/commands/view/command.tsx new file mode 100644 index 000000000..e656e8b3c --- /dev/null +++ b/src/cli/commands/view/command.tsx @@ -0,0 +1,129 @@ +import { ConfigIO, JobNotFoundError, serializeResult } from '../../../lib'; +import { createJobEngine } from '../../operations/jobs'; +import type { ABTestJobRecord, JobType } from '../../operations/jobs'; +import { getInvocationUrl, printABTestDetail, printABTestHistory } from '../../operations/jobs/ab-test/format'; +import { printBatchEvaluationDetail, printBatchEvaluationHistory } from '../../operations/jobs/batch-evaluation/format'; +import { printInsightsDetail, printInsightsHistory } from '../../operations/jobs/insights/format'; +import { printRecommendationDetail, printRecommendationHistory } from '../../operations/jobs/recommendation/format'; +import { runCliCommand } from '../../telemetry/cli-command-run'; +import { requireProject } from '../../tui/guards'; +import type { Command } from '@commander-js/extra-typings'; + +const TYPE_META: Record< + JobType, + { + label: string; + jsonKey: string; + printHistory: (records: unknown[]) => void; + printDetail: (record: unknown) => void; + } +> = { + recommendation: { + label: 'recommendation', + jsonKey: 'recommendations', + printHistory: printRecommendationHistory as (r: unknown[]) => void, + printDetail: printRecommendationDetail as (r: unknown) => void, + }, + 'batch-evaluation': { + label: 'batch evaluation', + jsonKey: 'batchEvaluations', + printHistory: printBatchEvaluationHistory as (r: unknown[]) => void, + printDetail: printBatchEvaluationDetail as (r: unknown) => void, + }, + 'ab-test': { + label: 'A/B test', + jsonKey: 'abTests', + printHistory: printABTestHistory as (r: unknown[]) => void, + printDetail: printABTestDetail as (r: unknown) => void, + }, + insights: { + label: 'insights', + jsonKey: 'insights', + printHistory: printInsightsHistory as (r: unknown[]) => void, + printDetail: printInsightsDetail as (r: unknown) => void, + }, +}; + +function registerViewSubcommand(viewCmd: Command, type: JobType) { + const meta = TYPE_META[type]; + + viewCmd + .command(type) + .description(`View ${meta.label} jobs`) + .argument('[id]', `${meta.label} job ID`) + .option('--json', 'Output as JSON (non-interactive)') + .option('--region ', 'AWS region (auto-detected if omitted)') + .action((id: string | undefined, cliOptions: { json?: boolean; region?: string }) => { + requireProject(); + + if (id) { + // Detail for one job + if (cliOptions.json) { + return runCliCommand('job.get', true, async () => { + const engine = createJobEngine(new ConfigIO()); + const record = await engine.get(type, id); + if (!record) { + throw new JobNotFoundError(`${meta.label} "${id}" not found.`); + } + const extra = + type === 'ab-test' ? { invocationUrl: getInvocationUrl(record as unknown as ABTestJobRecord) } : {}; + console.log(JSON.stringify(serializeResult({ success: true, ...record, ...extra }))); + return { job_type: type }; + }); + } + // Interactive detail — launch TUI + return launchTuiDetail(type, id); + } + + // List all jobs of this type + if (cliOptions.json) { + return runCliCommand('job.history', true, async () => { + const engine = createJobEngine(new ConfigIO()); + const records = await engine.list({ type }); + console.log( + JSON.stringify({ + success: true, + [meta.jsonKey]: records, + }) + ); + return { job_type: type }; + }); + } + // Interactive list — launch TUI + return launchTuiList(type); + }); +} + +async function launchTuiList(type: JobType): Promise { + const [{ render }, { default: React }] = await Promise.all([import('ink'), import('react')]); + + if (type === 'ab-test') { + const { ABTestJobsHistoryScreen } = await import('../../tui/screens/run-ab-test'); + render(React.createElement(ABTestJobsHistoryScreen, { onExit: () => process.exit(0) })); + } else if (type === 'batch-evaluation') { + const { BatchEvalHistoryScreen } = await import('../../tui/screens/run-eval'); + render(React.createElement(BatchEvalHistoryScreen, { onExit: () => process.exit(0) })); + } else if (type === 'insights') { + const { InsightsJobsScreen } = await import('../../tui/screens/insights-jobs'); + render(React.createElement(InsightsJobsScreen, { onExit: () => process.exit(0) })); + } else { + const { RecommendationHistoryScreen } = await import('../../tui/screens/recommendation'); + render(React.createElement(RecommendationHistoryScreen, { onExit: () => process.exit(0) })); + } + return new Promise(() => undefined); +} + +async function launchTuiDetail(type: JobType, id: string): Promise { + const [{ render }, { default: React }] = await Promise.all([import('ink'), import('react')]); + const { JobDetailScreen } = await import('./JobDetailScreen'); + render(React.createElement(JobDetailScreen, { type, id, onExit: () => process.exit(0) })); + return new Promise(() => undefined); +} + +export const registerView = (program: Command) => { + const viewCmd = program.command('view').description('View job history and details'); + registerViewSubcommand(viewCmd, 'recommendation'); + registerViewSubcommand(viewCmd, 'batch-evaluation'); + registerViewSubcommand(viewCmd, 'ab-test'); + registerViewSubcommand(viewCmd, 'insights'); +}; diff --git a/src/cli/commands/view/index.ts b/src/cli/commands/view/index.ts new file mode 100644 index 000000000..79c9cba1a --- /dev/null +++ b/src/cli/commands/view/index.ts @@ -0,0 +1 @@ +export { registerView } from './command'; diff --git a/src/cli/constants.ts b/src/cli/constants.ts index 3f7da3fdb..deda321d7 100644 --- a/src/cli/constants.ts +++ b/src/cli/constants.ts @@ -28,17 +28,18 @@ export const COMMAND_DESCRIPTIONS = { fetch: 'Fetch access info for deployed resources.', pause: 'Pause a deployed resource (online eval config, A/B test).', resume: 'Resume a paused resource (online eval config, A/B test).', - recommend: '[preview] Run optimization recommendations for system prompts and tool descriptions.', - recommendations: '[preview] View recommendation history from past runs.', + recommend: 'Run optimization recommendations for system prompts and tool descriptions.', + recommendations: 'View recommendation history from past runs.', run: 'Run evaluations, batch evaluations, or optimization recommendations.', stop: 'Stop a running batch evaluation or A/B test.', import: 'Import a runtime, memory, or starter toolkit into this project. [experimental]', telemetry: 'Manage anonymous usage analytics preferences.', update: 'Check for and install CLI updates', validate: 'Validate agentcore/ config files.', - 'config-bundle': '[preview] Manage configuration bundle versions and diffs.', - archive: '[preview] Archive (delete) a batch evaluation or recommendation on the service and clear local history.', + 'config-bundle': 'Manage configuration bundle versions and diffs.', + archive: 'Archive (delete) a batch evaluation or recommendation on the service and clear local history.', config: 'Adjust global configuration settings such as telemetry opt-out status', + export: 'Export a harness to a Strands runtime agent.', } as const; /** diff --git a/src/cli/external-requirements/__tests__/checks-extended.test.ts b/src/cli/external-requirements/__tests__/checks-extended.test.ts index 130e9c43c..7b8339502 100644 --- a/src/cli/external-requirements/__tests__/checks-extended.test.ts +++ b/src/cli/external-requirements/__tests__/checks-extended.test.ts @@ -48,6 +48,7 @@ describe('requiresUv', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -55,7 +56,6 @@ describe('requiresUv', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -79,6 +79,7 @@ describe('requiresUv', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -86,7 +87,6 @@ describe('requiresUv', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -101,6 +101,7 @@ describe('requiresUv', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -108,7 +109,6 @@ describe('requiresUv', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -134,6 +134,7 @@ describe('requiresContainerRuntime', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -141,7 +142,6 @@ describe('requiresContainerRuntime', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -165,6 +165,7 @@ describe('requiresContainerRuntime', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -172,7 +173,6 @@ describe('requiresContainerRuntime', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -187,6 +187,7 @@ describe('requiresContainerRuntime', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -194,7 +195,6 @@ describe('requiresContainerRuntime', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -226,6 +226,7 @@ describe('requiresContainerRuntime', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -233,7 +234,6 @@ describe('requiresContainerRuntime', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -299,6 +299,7 @@ describe('checkDependencyVersions', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -306,7 +307,6 @@ describe('checkDependencyVersions', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -325,6 +325,7 @@ describe('checkDependencyVersions', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -332,7 +333,6 @@ describe('checkDependencyVersions', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -359,6 +359,7 @@ describe('checkDependencyVersions', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -366,7 +367,6 @@ describe('checkDependencyVersions', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], diff --git a/src/cli/feature-flags.ts b/src/cli/feature-flags.ts index f6dce4f86..03d08854d 100644 --- a/src/cli/feature-flags.ts +++ b/src/cli/feature-flags.ts @@ -1,3 +1,5 @@ declare const __PREVIEW__: boolean; export const isPreviewEnabled = (): boolean => __PREVIEW__; + +export const isGatedFeaturesEnabled = (): boolean => process.env.ENABLE_GATED_FEATURES === '1'; diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index b58e86593..30cc5642b 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -17,11 +17,12 @@ export interface RemoveLoggerOptions { | 'runtime-endpoint' | 'evaluator' | 'online-eval' + | 'online-insights' | 'policy-engine' | 'policy' | 'config-bundle' - | 'ab-test' | 'dataset' + | 'knowledge-base' | 'payment-manager' | 'payment-connector'; /** Name of the resource being removed */ diff --git a/src/cli/operations/ab-test/__tests__/promote.test.ts b/src/cli/operations/ab-test/__tests__/promote.test.ts deleted file mode 100644 index 2abf8583c..000000000 --- a/src/cli/operations/ab-test/__tests__/promote.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { promoteABTestConfig } from '../promote'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// Mock ConfigIO — vi.hoisted ensures these are available before the hoisted vi.mock runs -const { mockReadProjectSpec, mockWriteProjectSpec, mockReadDeployedState } = vi.hoisted(() => ({ - mockReadProjectSpec: vi.fn(), - mockWriteProjectSpec: vi.fn(), - mockReadDeployedState: vi.fn(), -})); - -vi.mock('../../../../lib', () => { - class MockConfigIO { - readProjectSpec = mockReadProjectSpec; - writeProjectSpec = mockWriteProjectSpec; - readDeployedState = mockReadDeployedState; - } - return { ConfigIO: MockConfigIO }; -}); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeConfigBundleProject(testName = 'myTest') { - return { - name: 'TestProject', - runtimes: [], - httpGateways: [], - onlineEvalConfigs: [], - abTests: [ - { - name: testName, - mode: 'config-bundle' as const, - gatewayRef: '{{gateway:my-gw}}', - variants: [ - { - name: 'C' as const, - weight: 50, - variantConfiguration: { - configurationBundle: { bundleArn: 'arn:aws:bundle:control', bundleVersion: 'v1' }, - }, - }, - { - name: 'T1' as const, - weight: 50, - variantConfiguration: { - configurationBundle: { bundleArn: 'arn:aws:bundle:treatment', bundleVersion: 'v2' }, - }, - }, - ], - evaluationConfig: { onlineEvaluationConfigArn: 'arn:aws:eval:config' }, - }, - ], - }; -} - -function makeTargetBasedProject(testName = 'targetTest') { - return { - name: 'TestProject', - runtimes: [ - { - name: 'my-runtime', - endpoints: { - control: { version: '1.0' }, - treatment: { version: '2.0' }, - }, - }, - ], - httpGateways: [ - { - name: 'my-gw', - targets: [ - { name: 'ctrl-target', runtimeRef: 'my-runtime', qualifier: 'control' }, - { name: 'treat-target', runtimeRef: 'my-runtime', qualifier: 'treatment' }, - ], - }, - ], - onlineEvalConfigs: [], - abTests: [ - { - name: testName, - mode: 'target-based' as const, - gatewayRef: '{{gateway:my-gw}}', - variants: [ - { - name: 'C' as const, - weight: 50, - variantConfiguration: { target: { targetName: 'ctrl-target' } }, - }, - { - name: 'T1' as const, - weight: 50, - variantConfiguration: { target: { targetName: 'treat-target' } }, - }, - ], - evaluationConfig: { - perVariantOnlineEvaluationConfig: [ - { treatmentName: 'C' as const, onlineEvaluationConfigArn: 'eval-c' }, - { treatmentName: 'T1' as const, onlineEvaluationConfigArn: 'eval-t1' }, - ], - }, - }, - ], - }; -} - -function makeDeployedState(specName: string, abTestId: string) { - return { - targets: { - default: { - resources: { - abTests: { - [specName]: { abTestId, abTestArn: `arn:aws:ab-test:${abTestId}` }, - }, - }, - }, - }, - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('promoteABTestConfig', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockWriteProjectSpec.mockResolvedValue(undefined); - }); - - describe('target-based promote', () => { - it('updates control endpoint version to treatment version', async () => { - const project = makeTargetBasedProject(); - mockReadProjectSpec.mockResolvedValue(project); - mockReadDeployedState.mockResolvedValue(makeDeployedState('targetTest', 'ab-123')); - - const result = await promoteABTestConfig('ab-123'); - - expect(result.promoted).toBe(true); - expect(result.mode).toBe('target-based'); - expect(result.promotionDetail).toContain('control'); - expect(result.promotionDetail).toContain('2.0'); - - // Verify the project was written with updated control version - expect(mockWriteProjectSpec).toHaveBeenCalledOnce(); - const writtenProject = mockWriteProjectSpec.mock.calls[0]![0]; - expect(writtenProject.runtimes[0].endpoints.control.version).toBe('2.0'); - }); - }); - - describe('config-bundle promote', () => { - it('copies treatment bundle ref to control', async () => { - const project = makeConfigBundleProject(); - mockReadProjectSpec.mockResolvedValue(project); - mockReadDeployedState.mockResolvedValue(makeDeployedState('myTest', 'ab-456')); - - const result = await promoteABTestConfig('ab-456'); - - expect(result.promoted).toBe(true); - expect(result.mode).toBe('config-bundle'); - expect(result.promotionDetail).toContain('arn:aws:bundle:treatment'); - expect(result.promotionDetail).toContain('v2'); - - // Verify the control bundle was updated - expect(mockWriteProjectSpec).toHaveBeenCalledOnce(); - const writtenProject = mockWriteProjectSpec.mock.calls[0]![0]; - const controlVariant = writtenProject.abTests[0].variants.find((v: { name: string }) => v.name === 'C'); - expect(controlVariant.variantConfiguration.configurationBundle.bundleArn).toBe('arn:aws:bundle:treatment'); - expect(controlVariant.variantConfiguration.configurationBundle.bundleVersion).toBe('v2'); - }); - }); - - describe('not found', () => { - it('returns promoted=false with message when AB test not found', async () => { - const project = makeConfigBundleProject(); - mockReadProjectSpec.mockResolvedValue(project); - mockReadDeployedState.mockResolvedValue({ targets: { default: { resources: { abTests: {} } } } }); - - const result = await promoteABTestConfig('nonexistent-id'); - - expect(result.promoted).toBe(false); - expect(result.promotionDetail).toContain('not found'); - expect(mockWriteProjectSpec).not.toHaveBeenCalled(); - }); - }); - - describe('ID-based lookup from deployed state', () => { - it('resolves spec name from deployed state using abTestId', async () => { - const project = makeConfigBundleProject('mySpecTest'); - mockReadProjectSpec.mockResolvedValue(project); - mockReadDeployedState.mockResolvedValue(makeDeployedState('mySpecTest', 'ab-789')); - - const result = await promoteABTestConfig('ab-789'); - - expect(result.promoted).toBe(true); - expect(result.mode).toBe('config-bundle'); - // Should have resolved without needing testNameFallback - expect(mockWriteProjectSpec).toHaveBeenCalledOnce(); - }); - - it('searches across multiple targets in deployed state', async () => { - const project = makeConfigBundleProject('crossTarget'); - mockReadProjectSpec.mockResolvedValue(project); - mockReadDeployedState.mockResolvedValue({ - targets: { - 'us-east-1': { resources: { abTests: {} } }, - 'us-west-2': { - resources: { - abTests: { - crossTarget: { abTestId: 'ab-cross', abTestArn: 'arn:aws:ab-test:ab-cross' }, - }, - }, - }, - }, - }); - - const result = await promoteABTestConfig('ab-cross'); - - expect(result.promoted).toBe(true); - }); - }); - - describe('name fallback when deployed state missing', () => { - it('falls back to name-based lookup when deployed state throws', async () => { - const project = makeConfigBundleProject('fallbackTest'); - mockReadProjectSpec.mockResolvedValue(project); - mockReadDeployedState.mockRejectedValue(new Error('No deployed state')); - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); - - const result = await promoteABTestConfig('unknown-id', 'fallbackTest'); - - expect(result.promoted).toBe(true); - expect(result.mode).toBe('config-bundle'); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('falling back to name')); - - warnSpy.mockRestore(); - }); - - it('falls back to prefixed name match', async () => { - const project = makeConfigBundleProject('myTest'); - mockReadProjectSpec.mockResolvedValue(project); - mockReadDeployedState.mockRejectedValue(new Error('No deployed state')); - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); - - // testNameFallback uses the prefixed format {projectName}_{testName} - const result = await promoteABTestConfig('unknown-id', 'TestProject_myTest'); - - expect(result.promoted).toBe(true); - - warnSpy.mockRestore(); - }); - - it('returns not found when neither deployed state nor name matches', async () => { - const project = makeConfigBundleProject('myTest'); - mockReadProjectSpec.mockResolvedValue(project); - mockReadDeployedState.mockRejectedValue(new Error('No deployed state')); - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn()); - - const result = await promoteABTestConfig('unknown-id', 'nonexistent'); - - expect(result.promoted).toBe(false); - expect(result.promotionDetail).toContain('not found'); - - warnSpy.mockRestore(); - }); - }); -}); diff --git a/src/cli/operations/ab-test/promote.ts b/src/cli/operations/ab-test/promote.ts deleted file mode 100644 index 5f98e52f6..000000000 --- a/src/cli/operations/ab-test/promote.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { ConfigIO } from '../../../lib'; - -export interface PromoteABTestResult { - promoted: boolean; - mode?: string; - promotionDetail: string; -} - -/** - * Resolve the spec-level AB test name from a deployed abTestId. - * Looks up which entry in deployed state has that abTestId and returns - * the spec name (the key in the abTests record). - */ -function resolveSpecNameFromDeployedState( - configIO: ConfigIO, - deployedState: { targets: Record } }> }, - abTestId: string -): string | undefined { - for (const target of Object.values(deployedState.targets)) { - const abTests = target.resources?.abTests; - if (!abTests) continue; - for (const [specName, entry] of Object.entries(abTests)) { - if (entry.abTestId === abTestId) { - return specName; - } - } - } - return undefined; -} - -/** - * Apply AB test promotion to agentcore.json. - * Updates the control variant's config to match the treatment variant. - * Does NOT stop the AB test — caller is responsible for that. - * - * @param abTestId - The deployed AB test ID - * @param testNameFallback - Optional name fallback when deployed state is unavailable - */ -export async function promoteABTestConfig(abTestId: string, testNameFallback?: string): Promise { - const configIO = new ConfigIO(); - const project = await configIO.readProjectSpec(); - - // Try to resolve spec name from deployed state - let specName: string | undefined; - try { - const deployedState = await configIO.readDeployedState(); - specName = resolveSpecNameFromDeployedState(configIO, deployedState, abTestId); - } catch { - // Deployed state unavailable - } - - // Fall back to name-based lookup if deployed state didn't resolve - if (!specName && testNameFallback) { - console.warn( - `[promote] Could not resolve AB test ID "${abTestId}" from deployed state; falling back to name "${testNameFallback}".` - ); - const lowerName = testNameFallback.toLowerCase(); - const match = (project.abTests ?? []).find( - t => t.name.toLowerCase() === lowerName || `${project.name}_${t.name}`.toLowerCase() === lowerName - ); - specName = match?.name; - } - - const abTest = specName ? (project.abTests ?? []).find(t => t.name === specName) : undefined; - - if (!abTest) { - return { promoted: false, promotionDetail: `AB test with ID "${abTestId}" not found in project config.` }; - } - - const mode = abTest.mode ?? 'config-bundle'; - - if (abTest.mode === 'target-based') { - const treatmentVariant = abTest.variants.find(v => v.name === 'T1'); - const controlVariant = abTest.variants.find(v => v.name === 'C'); - const controlTargetName = controlVariant?.variantConfiguration.target?.targetName; - const treatmentTargetName = treatmentVariant?.variantConfiguration.target?.targetName; - - const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(abTest.gatewayRef); - const gwName = gwMatch?.[1]; - if (gwName) { - const gw = (project.httpGateways ?? []).find(g => g.name === gwName); - if (gw?.targets) { - const controlTarget = gw.targets.find(t => t.name === controlTargetName); - const treatmentTarget = gw.targets.find(t => t.name === treatmentTargetName); - - if (controlTarget && treatmentTarget) { - const runtime = project.runtimes.find(r => r.name === controlTarget.runtimeRef); - const controlEp = runtime?.endpoints?.[controlTarget.qualifier]; - const treatmentEp = runtime?.endpoints?.[treatmentTarget.qualifier]; - if (controlEp && treatmentEp) { - controlEp.version = treatmentEp.version; - await configIO.writeProjectSpec(project); - return { - promoted: true, - mode, - promotionDetail: `Control endpoint "${controlTarget.qualifier}" updated to version ${treatmentEp.version} (from treatment "${treatmentTarget.qualifier}").`, - }; - } - } - } - } - return { promoted: false, mode, promotionDetail: 'Could not resolve target endpoints for promotion.' }; - } - - // Config-bundle mode - const controlVariant = abTest.variants.find(v => v.name === 'C'); - const treatmentVariant = abTest.variants.find(v => v.name === 'T1'); - if ( - controlVariant?.variantConfiguration.configurationBundle && - treatmentVariant?.variantConfiguration.configurationBundle - ) { - controlVariant.variantConfiguration.configurationBundle = { - ...treatmentVariant.variantConfiguration.configurationBundle, - }; - await configIO.writeProjectSpec(project); - return { - promoted: true, - mode, - promotionDetail: `Control bundle updated to "${treatmentVariant.variantConfiguration.configurationBundle.bundleArn}" version "${treatmentVariant.variantConfiguration.configurationBundle.bundleVersion}".`, - }; - } - - return { promoted: false, mode, promotionDetail: 'Could not resolve config bundles for promotion.' }; -} diff --git a/src/cli/operations/agent/generate/write-agent-to-project.ts b/src/cli/operations/agent/generate/write-agent-to-project.ts index 55056c4b2..8163494f6 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -66,6 +66,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ managedBy: 'CDK' as const, runtimes: [agent], memories, + knowledgeBases: [], credentials, evaluators: [], onlineEvalConfigs: [], @@ -73,7 +74,6 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], diff --git a/src/cli/operations/archive/__tests__/archive-storage.test.ts b/src/cli/operations/archive/__tests__/archive-storage.test.ts deleted file mode 100644 index 9ebb41fd5..000000000 --- a/src/cli/operations/archive/__tests__/archive-storage.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { deleteLocalBatchEvalRun, deleteLocalRecommendationRun } from '../archive-storage.js'; -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockFindConfigRoot = vi.fn(); - -vi.mock('../../../../lib', () => ({ - findConfigRoot: () => mockFindConfigRoot(), -})); - -function makeTmpDir(): string { - const dir = join(tmpdir(), `archive-storage-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function writeJsonFile(path: string, data: unknown): void { - mkdirSync(join(path, '..'), { recursive: true }); - writeFileSync(path, JSON.stringify(data)); -} - -describe('archive-storage', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = makeTmpDir(); - mockFindConfigRoot.mockReturnValue(tmpDir); - }); - - afterEach(() => { - if (existsSync(tmpDir)) { - rmSync(tmpDir, { recursive: true, force: true }); - } - vi.clearAllMocks(); - }); - - describe('deleteLocalBatchEvalRun', () => { - it('deletes the file and returns true when file exists', () => { - const filePath = join(tmpDir, '.cli', 'batch-eval-results', 'eval-123.json'); - writeJsonFile(filePath, { batchEvaluationId: 'eval-123' }); - - const result = deleteLocalBatchEvalRun('eval-123'); - - expect(result).toBe(true); - expect(existsSync(filePath)).toBe(false); - }); - - it('returns false when file does not exist', () => { - const result = deleteLocalBatchEvalRun('nonexistent-id'); - expect(result).toBe(false); - }); - - it('does not throw when the batch-eval-results directory does not exist', () => { - expect(() => deleteLocalBatchEvalRun('any-id')).not.toThrow(); - }); - - it('throws when findConfigRoot returns null', () => { - mockFindConfigRoot.mockReturnValue(null); - expect(() => deleteLocalBatchEvalRun('eval-123')).toThrow('No agentcore project found'); - }); - - it('throws when id contains a forward slash', () => { - expect(() => deleteLocalBatchEvalRun('../evil')).toThrow('Invalid batch evaluation ID'); - }); - - it('throws when id contains a backslash', () => { - expect(() => deleteLocalBatchEvalRun('evil\\path')).toThrow('Invalid batch evaluation ID'); - }); - - it('leaves other files in the directory untouched', () => { - const keep = join(tmpDir, '.cli', 'batch-eval-results', 'keep-me.json'); - const del = join(tmpDir, '.cli', 'batch-eval-results', 'delete-me.json'); - writeJsonFile(keep, { batchEvaluationId: 'keep-me' }); - writeJsonFile(del, { batchEvaluationId: 'delete-me' }); - - deleteLocalBatchEvalRun('delete-me'); - - expect(existsSync(keep)).toBe(true); - expect(existsSync(del)).toBe(false); - }); - }); - - describe('deleteLocalRecommendationRun', () => { - it('deletes the file and returns true when file exists', () => { - const filePath = join(tmpDir, '.cli', 'recommendations', 'rec-456.json'); - writeJsonFile(filePath, { recommendationId: 'rec-456' }); - - const result = deleteLocalRecommendationRun('rec-456'); - - expect(result).toBe(true); - expect(existsSync(filePath)).toBe(false); - }); - - it('returns false when file does not exist', () => { - const result = deleteLocalRecommendationRun('nonexistent-id'); - expect(result).toBe(false); - }); - - it('does not throw when the recommendations directory does not exist', () => { - expect(() => deleteLocalRecommendationRun('any-id')).not.toThrow(); - }); - - it('throws when findConfigRoot returns null', () => { - mockFindConfigRoot.mockReturnValue(null); - expect(() => deleteLocalRecommendationRun('rec-456')).toThrow('No agentcore project found'); - }); - - it('throws when id contains a forward slash', () => { - expect(() => deleteLocalRecommendationRun('../evil')).toThrow('Invalid recommendation ID'); - }); - - it('throws when id contains a backslash', () => { - expect(() => deleteLocalRecommendationRun('evil\\path')).toThrow('Invalid recommendation ID'); - }); - - it('leaves other files in the directory untouched', () => { - const keep = join(tmpDir, '.cli', 'recommendations', 'keep-me.json'); - const del = join(tmpDir, '.cli', 'recommendations', 'delete-me.json'); - writeJsonFile(keep, { recommendationId: 'keep-me' }); - writeJsonFile(del, { recommendationId: 'delete-me' }); - - deleteLocalRecommendationRun('delete-me'); - - expect(existsSync(keep)).toBe(true); - expect(existsSync(del)).toBe(false); - }); - }); -}); diff --git a/src/cli/operations/archive/archive-storage.ts b/src/cli/operations/archive/archive-storage.ts deleted file mode 100644 index 5b4481fda..000000000 --- a/src/cli/operations/archive/archive-storage.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { findConfigRoot } from '../../../lib'; -import { BATCH_EVAL_RESULTS_DIR } from '../eval/batch-eval-storage'; -import { RECOMMENDATIONS_DIR } from '../recommendation/recommendation-storage'; -import { existsSync, rmSync } from 'fs'; -import { join } from 'path'; - -function getCliDir(): string { - const configRoot = findConfigRoot(); - if (!configRoot) { - throw new Error('No agentcore project found. Run `agentcore create` first.'); - } - return join(configRoot, '.cli'); -} - -function assertSafeId(id: string, label: string): void { - if (/[/\\]/.test(id)) { - throw new Error(`Invalid ${label}: must not contain path separators`); - } -} - -/** - * Delete the local batch eval run record for the given ID. - * Returns true if the file existed and was deleted, false if it was not found. - */ -export function deleteLocalBatchEvalRun(batchEvaluationId: string): boolean { - assertSafeId(batchEvaluationId, 'batch evaluation ID'); - const filePath = join(getCliDir(), BATCH_EVAL_RESULTS_DIR, `${batchEvaluationId}.json`); - if (!existsSync(filePath)) return false; - rmSync(filePath); - return true; -} - -/** - * Delete the local recommendation run record for the given ID. - * Returns true if the file existed and was deleted, false if it was not found. - */ -export function deleteLocalRecommendationRun(recommendationId: string): boolean { - assertSafeId(recommendationId, 'recommendation ID'); - const filePath = join(getCliDir(), RECOMMENDATIONS_DIR, `${recommendationId}.json`); - if (!existsSync(filePath)) return false; - rmSync(filePath); - return true; -} diff --git a/src/cli/operations/archive/index.ts b/src/cli/operations/archive/index.ts deleted file mode 100644 index 0f5d523ba..000000000 --- a/src/cli/operations/archive/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { deleteLocalBatchEvalRun, deleteLocalRecommendationRun } from './archive-storage'; diff --git a/src/cli/operations/deploy/__tests__/managed-memory-notice.test.ts b/src/cli/operations/deploy/__tests__/managed-memory-notice.test.ts new file mode 100644 index 000000000..9d22e4cd4 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/managed-memory-notice.test.ts @@ -0,0 +1,78 @@ +import type { ConfigIO } from '../../../../lib'; +import { + MANAGED_MEMORY_ADD_NOTICE, + MANAGED_MEMORY_DEPLOY_NOTICE, + hasManagedMemoryHarness, +} from '../managed-memory-notice'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * Builds a stub ConfigIO whose readHarnessSpec returns the given mode per harness name. + * Unknown names reject (mirrors a missing/unreadable harness.json). + */ +function stubConfigIO(modes: Record): ConfigIO { + return { + readHarnessSpec: vi.fn((name: string) => { + if (!(name in modes)) { + return Promise.reject(new Error(`no spec for ${name}`)); + } + const mode = modes[name]; + return Promise.resolve({ memory: mode ? { mode } : undefined } as never); + }), + } as unknown as ConfigIO; +} + +describe('hasManagedMemoryHarness', () => { + const originalGate = process.env.ENABLE_GATED_FEATURES; + + beforeEach(() => { + process.env.ENABLE_GATED_FEATURES = '1'; + }); + + afterEach(() => { + if (originalGate === undefined) { + delete process.env.ENABLE_GATED_FEATURES; + } else { + process.env.ENABLE_GATED_FEATURES = originalGate; + } + vi.clearAllMocks(); + }); + + it('returns false when the gate is off, even with a managed harness', async () => { + delete process.env.ENABLE_GATED_FEATURES; + const configIO = stubConfigIO({ h1: 'managed' }); + expect(await hasManagedMemoryHarness(configIO, [{ name: 'h1' }])).toBe(false); + }); + + it('returns false when there are no harnesses', async () => { + expect(await hasManagedMemoryHarness(stubConfigIO({}), [])).toBe(false); + expect(await hasManagedMemoryHarness(stubConfigIO({}), undefined)).toBe(false); + }); + + it('returns true when any harness uses managed memory', async () => { + const configIO = stubConfigIO({ h1: 'existing', h2: 'managed' }); + expect(await hasManagedMemoryHarness(configIO, [{ name: 'h1' }, { name: 'h2' }])).toBe(true); + }); + + it('returns false when all harnesses are existing or disabled', async () => { + const configIO = stubConfigIO({ h1: 'existing', h2: 'disabled' }); + expect(await hasManagedMemoryHarness(configIO, [{ name: 'h1' }, { name: 'h2' }])).toBe(false); + }); + + it('treats an unreadable harness spec as non-managed (does not throw)', async () => { + const configIO = stubConfigIO({ h1: 'managed' }); + expect(await hasManagedMemoryHarness(configIO, [{ name: 'missing' }, { name: 'h1' }])).toBe(true); + }); +}); + +describe('managed-memory notice text', () => { + it('deploy notice tells the user how to skip via redeploy', () => { + expect(MANAGED_MEMORY_DEPLOY_NOTICE).toContain('3-5 minutes'); + expect(MANAGED_MEMORY_DEPLOY_NOTICE).toContain('redeploy with --memory-mode disabled'); + }); + + it('add notice is future-tense and points at the next deploy', () => { + expect(MANAGED_MEMORY_ADD_NOTICE).toContain('will automatically provision'); + expect(MANAGED_MEMORY_ADD_NOTICE).toContain('on deploy'); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts deleted file mode 100644 index a206fc23b..000000000 --- a/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts +++ /dev/null @@ -1,660 +0,0 @@ -import type { AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema'; -import { deleteOrphanedABTests, setupABTests } from '../post-deploy-ab-tests.js'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// ── Hoisted mocks ────────────────────────────────────────────────────────── - -const { - mockCreateABTest, - mockDeleteABTest, - mockGetABTest, - mockUpdateABTest, - mockListABTests, - mockGetCredentialProvider, - mockIAMSend, -} = vi.hoisted(() => ({ - mockCreateABTest: vi.fn(), - mockDeleteABTest: vi.fn(), - mockGetABTest: vi.fn(), - mockUpdateABTest: vi.fn(), - mockListABTests: vi.fn(), - mockGetCredentialProvider: vi.fn().mockReturnValue(undefined), - mockIAMSend: vi.fn(), -})); - -vi.mock('../../../aws/agentcore-ab-tests', () => ({ - createABTest: mockCreateABTest, - deleteABTest: mockDeleteABTest, - getABTest: mockGetABTest, - updateABTest: mockUpdateABTest, - listABTests: mockListABTests, -})); - -vi.mock('../../../aws/account', () => ({ - getCredentialProvider: mockGetCredentialProvider, -})); - -vi.mock('@aws-sdk/client-iam', () => ({ - IAMClient: class { - send = mockIAMSend; - }, - CreateRoleCommand: class { - constructor(public input: unknown) {} - }, - PutRolePolicyCommand: class { - constructor(public input: unknown) {} - }, - DeleteRolePolicyCommand: class { - constructor(public input: unknown) {} - }, - DeleteRoleCommand: class { - constructor(public input: unknown) {} - }, -})); - -// ── Helpers ──────────────────────────────────────────────────────────────── - -function makeProjectSpec(abTests: AgentCoreProjectSpec['abTests'] = []): AgentCoreProjectSpec { - return { - name: 'TestProject', - version: 1, - managedBy: 'CDK' as const, - runtimes: [], - memories: [], - credentials: [], - evaluators: [], - onlineEvalConfigs: [], - agentCoreGateways: [], - policyEngines: [], - configBundles: [], - httpGateways: [], - datasets: [], - abTests, - harnesses: [], - payments: [], - }; -} - -const sampleABTest = { - name: 'TestOne', - mode: 'config-bundle' as const, - gatewayRef: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/gw-123', - variants: [ - { - name: 'C' as const, - weight: 80, - variantConfiguration: { configurationBundle: { bundleArn: 'arn:bundle:control', bundleVersion: 'v1' } }, - }, - { - name: 'T1' as const, - weight: 20, - variantConfiguration: { configurationBundle: { bundleArn: 'arn:bundle:treatment', bundleVersion: 'v1' } }, - }, - ], - evaluationConfig: { onlineEvaluationConfigArn: 'arn:eval:config' }, - roleArn: 'arn:aws:iam::123456789012:role/ExistingRole', -}; - -// ── Tests ────────────────────────────────────────────────────────────────── - -describe('setupABTests', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockListABTests.mockResolvedValue({ abTests: [] }); - mockUpdateABTest.mockResolvedValue({}); - mockGetABTest.mockResolvedValue({ status: 'ACTIVE', executionStatus: 'STOPPED' }); - }); - - describe('creation', () => { - it('creates new AB test when not in deployed state', async () => { - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-001', abTestArn: 'arn:abt:001' }); - - const result = await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([sampleABTest]), - }); - - expect(result.hasErrors).toBe(false); - expect(result.results).toHaveLength(1); - expect(result.results[0]!.status).toBe('created'); - expect(result.results[0]!.abTestId).toBe('abt-001'); - expect(result.abTests.TestOne).toEqual( - expect.objectContaining({ abTestId: 'abt-001', abTestArn: 'arn:abt:001' }) - ); - }); - - it('updates already-deployed test', async () => { - mockUpdateABTest.mockResolvedValue({ abTestId: 'abt-existing', abTestArn: 'arn:abt:existing' }); - - const result = await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([sampleABTest]), - existingABTests: { - TestOne: { abTestId: 'abt-existing', abTestArn: 'arn:abt:existing' }, - }, - }); - - expect(result.results[0]!.status).toBe('updated'); - expect(mockCreateABTest).not.toHaveBeenCalled(); - expect(mockUpdateABTest).toHaveBeenCalled(); - }); - - it('updates test found via API list (state loss recovery)', async () => { - mockListABTests.mockResolvedValue({ - abTests: [{ name: 'TestOne', abTestId: 'abt-api', abTestArn: 'arn:abt:api' }], - }); - mockUpdateABTest.mockResolvedValue({ abTestId: 'abt-api', abTestArn: 'arn:abt:api' }); - - const result = await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([sampleABTest]), - }); - - expect(result.results[0]!.status).toBe('updated'); - expect(result.abTests.TestOne!.abTestId).toBe('abt-api'); - expect(mockCreateABTest).not.toHaveBeenCalled(); - expect(mockUpdateABTest).toHaveBeenCalled(); - }); - - it('auto-creates IAM role when roleArn not provided', async () => { - const testWithoutRole = { ...sampleABTest, roleArn: undefined }; - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-002', abTestArn: 'arn:abt:002' }); - mockIAMSend.mockResolvedValue({ Role: { Arn: 'arn:aws:iam::123:role/AutoRole' } }); - - const result = await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([testWithoutRole]), - }); - - expect(result.results[0]!.status).toBe('created'); - expect(result.abTests.TestOne!.roleCreatedByCli).toBe(true); - expect(mockIAMSend).toHaveBeenCalled(); - }); - - it('uses provided roleArn without creating IAM role', async () => { - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-003', abTestArn: 'arn:abt:003' }); - - const result = await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([sampleABTest]), - }); - - expect(result.results[0]!.status).toBe('created'); - expect(result.abTests.TestOne!.roleCreatedByCli).toBe(false); - expect(mockIAMSend).not.toHaveBeenCalled(); - }); - - it('reports error when createABTest fails', async () => { - mockCreateABTest.mockRejectedValue(new Error('API failure')); - - const result = await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([sampleABTest]), - }); - - expect(result.hasErrors).toBe(true); - expect(result.results[0]!.status).toBe('error'); - expect(result.results[0]!.error).toBe('API failure'); - }); - }); - - describe('ARN resolution', () => { - it('resolves bundle name to ARN from deployed state', async () => { - const testWithNames = { - ...sampleABTest, - variants: [ - { - name: 'C' as const, - weight: 80, - variantConfiguration: { configurationBundle: { bundleArn: 'my-bundle', bundleVersion: 'LATEST' } }, - }, - { - name: 'T1' as const, - weight: 20, - variantConfiguration: { configurationBundle: { bundleArn: 'my-bundle', bundleVersion: 'v2' } }, - }, - ], - }; - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-004', abTestArn: 'arn:abt:004' }); - - await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([testWithNames]), - deployedResources: { - configBundles: { - 'my-bundle': { bundleArn: 'arn:bundle:resolved', versionId: 'ver-latest' }, - }, - } as unknown as DeployedResourceState, - }); - - const callArgs = mockCreateABTest.mock.calls[0]![0]; - expect(callArgs.variants[0].variantConfiguration.configurationBundle.bundleArn).toBe('arn:bundle:resolved'); - expect(callArgs.variants[0].variantConfiguration.configurationBundle.bundleVersion).toBe('ver-latest'); - expect(callArgs.variants[1].variantConfiguration.configurationBundle.bundleVersion).toBe('v2'); - }); - - it('resolves gateway placeholder to ARN', async () => { - const testWithPlaceholder = { - ...sampleABTest, - gatewayRef: '{{gateway:my-gw}}', - }; - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-005', abTestArn: 'arn:abt:005' }); - - await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([testWithPlaceholder]), - deployedResources: { - mcp: { - gateways: { - 'my-gw': { gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/resolved-gw' }, - }, - }, - } as unknown as DeployedResourceState, - }); - - expect(mockCreateABTest.mock.calls[0]![0].gatewayArn).toBe( - 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/resolved-gw' - ); - }); - - it('resolves gateway placeholder to ARN from HTTP gateways', async () => { - const testWithPlaceholder = { - ...sampleABTest, - gatewayRef: '{{gateway:my-http-gw}}', - }; - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-007', abTestArn: 'arn:abt:007' }); - - await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([testWithPlaceholder]), - deployedResources: { - httpGateways: { - 'my-http-gw': { - gatewayId: 'httpgw-001', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:httpgateway/httpgw-001', - }, - }, - } as unknown as DeployedResourceState, - }); - - expect(mockCreateABTest.mock.calls[0]![0].gatewayArn).toBe( - 'arn:aws:bedrock-agentcore:us-east-1:123:httpgateway/httpgw-001' - ); - }); - - it('resolves online eval config name to ARN', async () => { - const testWithEvalName = { - ...sampleABTest, - evaluationConfig: { onlineEvaluationConfigArn: 'my-eval-config' }, - }; - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-006', abTestArn: 'arn:abt:006' }); - - await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([testWithEvalName]), - deployedResources: { - onlineEvalConfigs: { - 'my-eval-config': { onlineEvaluationConfigArn: 'arn:eval:resolved' }, - }, - } as unknown as DeployedResourceState, - }); - - expect(mockCreateABTest.mock.calls[0]![0].evaluationConfig.onlineEvaluationConfigArn).toBe('arn:eval:resolved'); - }); - - it('resolves target-based variant with project prefix (runtime === projectName)', async () => { - const targetBasedTest = { - ...sampleABTest, - mode: 'target-based' as const, - variants: [ - { - name: 'C' as const, - weight: 90, - variantConfiguration: { target: { targetName: 'MyAgent-prod' } }, - }, - { - name: 'T1' as const, - weight: 10, - variantConfiguration: { target: { targetName: 'MyAgent-staging' } }, - }, - ], - }; - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-tgt-1', abTestArn: 'arn:abt:tgt1' }); - - await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([targetBasedTest]), - }); - - const callArgs = mockCreateABTest.mock.calls[0]![0]; - expect(callArgs.variants[0].variantConfiguration.target.name).toBe('TestProject-MyAgent-prod'); - expect(callArgs.variants[1].variantConfiguration.target.name).toBe('TestProject-MyAgent-staging'); - }); - - it('resolves target-based variant with project prefix (different project name)', async () => { - const targetBasedTest = { - ...sampleABTest, - mode: 'target-based' as const, - variants: [ - { - name: 'C' as const, - weight: 90, - variantConfiguration: { target: { targetName: 'Bar-prod' } }, - }, - { - name: 'T1' as const, - weight: 10, - variantConfiguration: { target: { targetName: 'Bar-staging' } }, - }, - ], - }; - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-tgt-2', abTestArn: 'arn:abt:tgt2' }); - - const spec = makeProjectSpec([targetBasedTest]); - spec.name = 'Foo'; - - await setupABTests({ - region: 'us-east-1', - projectSpec: spec, - }); - - const callArgs = mockCreateABTest.mock.calls[0]![0]; - expect(callArgs.variants[0].variantConfiguration.target.name).toBe('Foo-Bar-prod'); - expect(callArgs.variants[1].variantConfiguration.target.name).toBe('Foo-Bar-staging'); - }); - }); - - describe('deletion (reconciliation)', () => { - it('stops, polls until executionStatus is STOPPED, then deletes orphaned AB test', async () => { - const callOrder: string[] = []; - mockUpdateABTest.mockImplementation(() => { - callOrder.push('stop'); - return Promise.resolve({}); - }); - let getCallCount = 0; - mockGetABTest.mockImplementation(() => { - getCallCount++; - callOrder.push(`poll(${getCallCount})`); - // First poll: executionStatus not yet STOPPED (still transitioning) - if (getCallCount === 1) return Promise.resolve({ status: 'ACTIVE', executionStatus: 'RUNNING' }); - // Second poll: executionStatus is STOPPED — done - return Promise.resolve({ status: 'ACTIVE', executionStatus: 'STOPPED' }); - }); - mockDeleteABTest.mockImplementation(() => { - callOrder.push('delete'); - return Promise.resolve({ success: true }); - }); - - const result = await deleteOrphanedABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingABTests: { - RemovedTest: { abTestId: 'abt-old', abTestArn: 'arn:abt:old' }, - }, - }); - - // Verify: stop → poll (RUNNING) → poll (STOPPED) → delete - expect(callOrder).toEqual(['stop', 'poll(1)', 'poll(2)', 'delete']); - expect(mockUpdateABTest).toHaveBeenCalledWith({ - region: 'us-east-1', - abTestId: 'abt-old', - executionStatus: 'STOPPED', - }); - expect(result.results[0]!.status).toBe('deleted'); - }); - - it('proceeds with delete when stop fails (already stopped)', async () => { - mockUpdateABTest.mockRejectedValue(new Error('Cannot update in current state')); - mockDeleteABTest.mockResolvedValue({ success: true }); - - const result = await deleteOrphanedABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingABTests: { - RemovedTest: { abTestId: 'abt-stopped', abTestArn: 'arn:abt:stopped' }, - }, - }); - - expect(mockUpdateABTest).toHaveBeenCalled(); - expect(mockDeleteABTest).toHaveBeenCalled(); - expect(result.results[0]!.status).toBe('deleted'); - }); - - it('cleans up auto-created IAM role on deletion', async () => { - mockDeleteABTest.mockResolvedValue({ success: true }); - mockIAMSend.mockResolvedValue({}); - - await deleteOrphanedABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingABTests: { - RemovedTest: { - abTestId: 'abt-old', - abTestArn: 'arn:abt:old', - roleArn: 'arn:aws:iam::123:role/AutoCreatedRole', - roleCreatedByCli: true, - }, - }, - }); - - // Should have called delete policy + delete role - expect(mockIAMSend).toHaveBeenCalledTimes(2); - - // Verify first call is DeleteRolePolicyCommand - const firstCall = mockIAMSend.mock.calls[0]![0]; - expect(firstCall.input).toEqual( - expect.objectContaining({ RoleName: 'AutoCreatedRole', PolicyName: expect.any(String) }) - ); - - // Verify second call is DeleteRoleCommand - const secondCall = mockIAMSend.mock.calls[1]![0]; - expect(secondCall.input).toEqual(expect.objectContaining({ RoleName: 'AutoCreatedRole' })); - }); - - it('does not delete role when roleCreatedByCli is false', async () => { - mockDeleteABTest.mockResolvedValue({ success: true }); - - await deleteOrphanedABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingABTests: { - RemovedTest: { - abTestId: 'abt-old', - abTestArn: 'arn:abt:old', - roleArn: 'arn:aws:iam::123:role/UserRole', - roleCreatedByCli: false, - }, - }, - }); - - expect(mockIAMSend).not.toHaveBeenCalled(); - }); - - it('reports error when deletion fails', async () => { - mockDeleteABTest.mockRejectedValue(new Error('delete failed')); - - const result = await deleteOrphanedABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingABTests: { - FailTest: { abTestId: 'abt-fail', abTestArn: 'arn:abt:fail' }, - }, - }); - - expect(result.hasErrors).toBe(true); - expect(result.results[0]!.status).toBe('error'); - expect(result.results[0]!.error).toBe('delete failed'); - }); - - it('sets warning when AB test was stopped before deletion', async () => { - mockUpdateABTest.mockResolvedValue({}); - mockGetABTest.mockResolvedValue({ status: 'ACTIVE', executionStatus: 'STOPPED' }); - mockDeleteABTest.mockResolvedValue({ success: true }); - - const result = await deleteOrphanedABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingABTests: { - StoppedTest: { abTestId: 'abt-warn', abTestArn: 'arn:abt:warn' }, - }, - }); - - expect(result.results[0]!.status).toBe('deleted'); - expect(result.results[0]!.warning).toBe('AB test "StoppedTest" was stopped before deletion'); - }); - - it('does not set warning when stop fails (already stopped)', async () => { - mockUpdateABTest.mockRejectedValue(new Error('Cannot update')); - mockDeleteABTest.mockResolvedValue({ success: true }); - - const result = await deleteOrphanedABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingABTests: { - AlreadyStopped: { abTestId: 'abt-no-warn', abTestArn: 'arn:abt:no-warn' }, - }, - }); - - expect(result.results[0]!.status).toBe('deleted'); - expect(result.results[0]!.warning).toBeUndefined(); - }); - - it('proceeds with delete even when poll never reaches STOPPED (timeout)', async () => { - mockUpdateABTest.mockResolvedValue({}); - // executionStatus never becomes STOPPED — always RUNNING - mockGetABTest.mockResolvedValue({ status: 'ACTIVE', executionStatus: 'RUNNING' }); - mockDeleteABTest.mockResolvedValue({ success: true }); - - const result = await deleteOrphanedABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingABTests: { - StuckTest: { abTestId: 'abt-stuck', abTestArn: 'arn:abt:stuck' }, - }, - }); - - // Should still attempt delete after exhausting poll loop - expect(mockDeleteABTest).toHaveBeenCalledWith({ region: 'us-east-1', abTestId: 'abt-stuck' }); - expect(result.results[0]!.status).toBe('deleted'); - // Poll was called 20 times (the loop limit) - expect(mockGetABTest).toHaveBeenCalledTimes(20); - // Should warn that polling timed out - expect(result.results[0]!.warning).toBe( - 'AB test "StuckTest" did not reach STOPPED status within the polling window — proceeding with delete' - ); - }, 120_000); - - it('sets warning even when deleteABTest returns success: false', async () => { - mockUpdateABTest.mockResolvedValue({}); - mockGetABTest.mockResolvedValue({ status: 'ACTIVE', executionStatus: 'STOPPED' }); - mockDeleteABTest.mockResolvedValue({ success: false, error: 'still running' }); - - const result = await deleteOrphanedABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingABTests: { - FailAfterStop: { abTestId: 'abt-fail-stop', abTestArn: 'arn:abt:fail-stop' }, - }, - }); - - expect(result.results[0]!.status).toBe('error'); - expect(result.results[0]!.error).toBe('still running'); - // Warning should still be set because stop succeeded - expect(result.results[0]!.warning).toBe('AB test "FailAfterStop" was stopped before deletion'); - }); - }); - - describe('IAM role creation', () => { - it('creates role with correct trust policy and inline policy', async () => { - const testWithoutRole = { ...sampleABTest, roleArn: undefined }; - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-iam', abTestArn: 'arn:abt:iam' }); - mockIAMSend.mockResolvedValue({ Role: { Arn: 'arn:aws:iam::123:role/AutoRole' } }); - - await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([testWithoutRole]), - }); - - // First call: CreateRoleCommand with trust policy - const createRoleCall = mockIAMSend.mock.calls[0]![0]; - const trustPolicy = JSON.parse(createRoleCall.input.AssumeRolePolicyDocument); - expect(trustPolicy.Statement).toHaveLength(1); - expect(trustPolicy.Statement[0].Principal.Service).toBe('bedrock-agentcore.amazonaws.com'); - expect(trustPolicy.Statement[0].Condition.StringEquals['aws:SourceAccount']).toBeDefined(); - expect(trustPolicy.Statement[0].Condition.ArnLike['aws:SourceArn']).toContain('ab-test/*'); - - // Second call: PutRolePolicyCommand with inline policy - const putPolicyCall = mockIAMSend.mock.calls[1]![0]; - const policy = JSON.parse(putPolicyCall.input.PolicyDocument); - const sids = policy.Statement.map((s: { Sid: string }) => s.Sid); - expect(sids).toContain('AgentCoreResources'); - expect(sids).toContain('CloudWatchLogs'); - - // AgentCoreResources must include all required actions - const agentCoreStmt = policy.Statement.find((s: { Sid: string }) => s.Sid === 'AgentCoreResources'); - expect(agentCoreStmt.Action).toContain('bedrock-agentcore:GetEvaluator'); - expect(agentCoreStmt.Action).toContain('bedrock-agentcore:GetGateway'); - expect(agentCoreStmt.Action).toContain('bedrock-agentcore:GetOnlineEvaluationConfig'); - expect(agentCoreStmt.Condition.StringEquals['aws:ResourceAccount']).toBeDefined(); - }); - }); - - describe('edge cases', () => { - it('proceeds with creation when listABTests fails', async () => { - mockListABTests.mockRejectedValue(new Error('API unavailable')); - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-new', abTestArn: 'arn:abt:new' }); - - const result = await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([sampleABTest]), - }); - - expect(result.results[0]!.status).toBe('created'); - expect(mockCreateABTest).toHaveBeenCalled(); - }); - - it('swallows errors during IAM role deletion', async () => { - mockDeleteABTest.mockResolvedValue({ success: true }); - mockIAMSend.mockRejectedValue(new Error('IAM permission denied')); - - const result = await deleteOrphanedABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingABTests: { - OldTest: { - abTestId: 'abt-old', - abTestArn: 'arn:abt:old', - roleArn: 'arn:aws:iam::123:role/SomeRole', - roleCreatedByCli: true, - }, - }, - }); - - // Deletion should still succeed even though IAM cleanup failed - expect(result.results[0]!.status).toBe('deleted'); - }); - }); - - describe('mixed operations', () => { - it('creates new and updates existing', async () => { - const newTest = { ...sampleABTest, name: 'NewTest' }; - const keptTest = { ...sampleABTest, name: 'KeptTest' }; - - mockCreateABTest.mockResolvedValue({ abTestId: 'abt-new', abTestArn: 'arn:abt:new' }); - mockUpdateABTest.mockResolvedValue({ abTestId: 'abt-kept', abTestArn: 'arn:abt:kept' }); - - const result = await setupABTests({ - region: 'us-east-1', - projectSpec: makeProjectSpec([newTest, keptTest]), - existingABTests: { - KeptTest: { abTestId: 'abt-kept', abTestArn: 'arn:abt:kept' }, - }, - }); - - expect(result.results).toHaveLength(2); - const statuses = result.results.map(r => `${r.testName}:${r.status}`); - expect(statuses).toContain('NewTest:created'); - expect(statuses).toContain('KeptTest:updated'); - }); - }); -}); diff --git a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts deleted file mode 100644 index cd2dc82a8..000000000 --- a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts +++ /dev/null @@ -1,654 +0,0 @@ -import type { AgentCoreProjectSpec, DeployedState } from '../../../../schema'; -import { resolveConfigBundleComponentKeys, setupConfigBundles } from '../post-deploy-config-bundles.js'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { - mockCreateConfigurationBundle, - mockDeleteConfigurationBundle, - mockGetConfigurationBundleVersion, - mockListConfigurationBundleVersions, - mockListConfigurationBundles, - mockUpdateConfigurationBundle, -} = vi.hoisted(() => ({ - mockCreateConfigurationBundle: vi.fn(), - mockDeleteConfigurationBundle: vi.fn(), - mockGetConfigurationBundleVersion: vi.fn(), - mockListConfigurationBundleVersions: vi.fn(), - mockListConfigurationBundles: vi.fn(), - mockUpdateConfigurationBundle: vi.fn(), -})); - -vi.mock('../../../aws/agentcore-config-bundles', () => ({ - createConfigurationBundle: mockCreateConfigurationBundle, - deleteConfigurationBundle: mockDeleteConfigurationBundle, - getConfigurationBundleVersion: mockGetConfigurationBundleVersion, - listConfigurationBundleVersions: mockListConfigurationBundleVersions, - listConfigurationBundles: mockListConfigurationBundles, - updateConfigurationBundle: mockUpdateConfigurationBundle, -})); - -const REGION = 'us-west-2'; - -function makeProjectSpec(configBundles: Record[]) { - return { name: 'TestProject', configBundles } as any; -} - -describe('setupConfigBundles', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('create new bundle', () => { - it('should create a new bundle when not in existingBundles and not found by name', async () => { - mockListConfigurationBundles.mockResolvedValue({ bundles: [] }); - mockCreateConfigurationBundle.mockResolvedValue({ - bundleId: 'b-new', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-new', - versionId: 'v-1', - }); - - const result = await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([ - { name: 'MyBundle', type: 'ConfigurationBundle', components: { foo: { type: 'inline', value: 'bar' } } }, - ]), - }); - - expect(mockCreateConfigurationBundle).toHaveBeenCalledWith( - expect.objectContaining({ - region: REGION, - bundleName: 'TestProjectMyBundle', - components: { foo: { type: 'inline', value: 'bar' } }, - commitMessage: 'Create MyBundle', - }) - ); - expect(result.hasErrors).toBe(false); - expect(result.results).toHaveLength(1); - expect(result.results[0]).toMatchObject({ bundleName: 'MyBundle', status: 'created', bundleId: 'b-new' }); - expect(result.configBundles.MyBundle).toEqual({ - bundleId: 'b-new', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-new', - versionId: 'v-1', - }); - }); - }); - - describe('update existing bundle', () => { - it('should update an existing bundle when components have changed', async () => { - const existingBundles = { - MyBundle: { - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-1', - }, - }; - - mockGetConfigurationBundleVersion.mockResolvedValue({ - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-1', - components: { foo: { type: 'inline', value: 'old' } }, - description: undefined, - lineageMetadata: { branchName: 'main' }, - }); - - mockUpdateConfigurationBundle.mockResolvedValue({ - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-2', - }); - - const result = await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([ - { name: 'MyBundle', type: 'ConfigurationBundle', components: { foo: { type: 'inline', value: 'new' } } }, - ]), - existingBundles, - }); - - expect(mockUpdateConfigurationBundle).toHaveBeenCalledWith( - expect.objectContaining({ - region: REGION, - bundleId: 'b-123', - components: { foo: { type: 'inline', value: 'new' } }, - parentVersionIds: ['v-1'], - branchName: 'main', - commitMessage: 'Update MyBundle', - }) - ); - expect(result.results[0]).toMatchObject({ status: 'updated', versionId: 'v-2' }); - expect(result.hasErrors).toBe(false); - }); - }); - - describe('skip unchanged bundle', () => { - it('should skip update when components and description are unchanged', async () => { - const components = { foo: { type: 'inline', value: 'same' } }; - const existingBundles = { - MyBundle: { - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-1', - }, - }; - - mockGetConfigurationBundleVersion.mockResolvedValue({ - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-1', - components, - description: 'My desc', - lineageMetadata: { branchName: 'main' }, - }); - - const result = await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([ - { name: 'MyBundle', type: 'ConfigurationBundle', components, description: 'My desc' }, - ]), - existingBundles, - }); - - expect(mockUpdateConfigurationBundle).not.toHaveBeenCalled(); - expect(mockCreateConfigurationBundle).not.toHaveBeenCalled(); - expect(result.results[0]).toMatchObject({ bundleName: 'MyBundle', status: 'skipped', versionId: 'v-1' }); - expect(result.configBundles.MyBundle).toEqual(existingBundles.MyBundle); - }); - }); - - describe('deep equal is key-order-independent', () => { - it('should skip update when components differ only in key order', async () => { - const existingBundles = { - MyBundle: { - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-1', - }, - }; - - // API returns keys in one order - mockGetConfigurationBundleVersion.mockResolvedValue({ - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-1', - components: { a: { type: 'inline', value: '1' }, b: { type: 'inline', value: '2' } }, - description: undefined, - lineageMetadata: { branchName: 'main' }, - }); - - // Spec has same keys in different order - const result = await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([ - { - name: 'MyBundle', - components: { b: { type: 'inline', value: '2' }, a: { type: 'inline', value: '1' } }, - }, - ]), - existingBundles, - }); - - expect(mockUpdateConfigurationBundle).not.toHaveBeenCalled(); - expect(result.results[0]).toMatchObject({ status: 'skipped' }); - }); - }); - - describe('delete orphaned bundles', () => { - it('should delete bundles in existingBundles but not in projectSpec', async () => { - const existingBundles = { - OrphanBundle: { - bundleId: 'b-orphan', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-orphan', - versionId: 'v-1', - }, - }; - - mockDeleteConfigurationBundle.mockResolvedValue(undefined); - - const result = await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([]), - existingBundles, - }); - - expect(mockDeleteConfigurationBundle).toHaveBeenCalledWith({ - region: REGION, - bundleId: 'b-orphan', - }); - expect(result.results[0]).toMatchObject({ bundleName: 'OrphanBundle', status: 'deleted' }); - expect(result.hasErrors).toBe(false); - }); - - it('should report error status when delete throws', async () => { - const existingBundles = { - OrphanBundle: { - bundleId: 'b-orphan', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-orphan', - versionId: 'v-1', - }, - }; - - mockDeleteConfigurationBundle.mockRejectedValue(new Error('Access denied')); - - const result = await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([]), - existingBundles, - }); - - expect(result.results[0]).toMatchObject({ bundleName: 'OrphanBundle', status: 'error', error: 'Access denied' }); - expect(result.hasErrors).toBe(true); - }); - }); - - describe('uses branch from API when bundleSpec has no branchName', () => { - it('should use branchName from getConfigurationBundleVersion lineageMetadata', async () => { - const existingBundles = { - MyBundle: { - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-1', - }, - }; - - mockGetConfigurationBundleVersion.mockResolvedValue({ - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-1', - components: { old: { type: 'inline', value: 'data' } }, - description: undefined, - lineageMetadata: { branchName: 'feature-branch' }, - }); - - mockUpdateConfigurationBundle.mockResolvedValue({ - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-2', - }); - - await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([ - { - name: 'MyBundle', - components: { new: { type: 'inline', value: 'data' } }, - // no branchName specified - }, - ]), - existingBundles, - }); - - expect(mockUpdateConfigurationBundle).toHaveBeenCalledWith( - expect.objectContaining({ - branchName: 'feature-branch', - }) - ); - }); - - it('should prefer bundleSpec branchName over API branchName', async () => { - const existingBundles = { - MyBundle: { - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-1', - }, - }; - - mockGetConfigurationBundleVersion.mockResolvedValue({ - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-1', - components: { old: { type: 'inline', value: 'data' } }, - description: undefined, - lineageMetadata: { branchName: 'api-branch' }, - }); - - mockUpdateConfigurationBundle.mockResolvedValue({ - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-2', - }); - - await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([ - { - name: 'MyBundle', - components: { new: { type: 'inline', value: 'data' } }, - branchName: 'spec-branch', - }, - ]), - existingBundles, - }); - - expect(mockUpdateConfigurationBundle).toHaveBeenCalledWith( - expect.objectContaining({ - branchName: 'spec-branch', - }) - ); - }); - }); - - describe('fallback path via findBundleByName', () => { - it('should fall through to findBundleByName when getConfigurationBundleVersion throws 404', async () => { - const existingBundles = { - MyBundle: { - bundleId: 'b-old', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-old', - versionId: 'v-old', - }, - }; - - // First call (existing bundle path) throws 404 - mockGetConfigurationBundleVersion.mockRejectedValueOnce(new Error('404 not found')).mockResolvedValueOnce({ - bundleId: 'b-found', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-found', - versionId: 'v-latest', - components: { old: { type: 'inline', value: 'data' } }, - description: undefined, - lineageMetadata: { branchName: 'main' }, - }); - - mockListConfigurationBundles.mockResolvedValue({ - bundles: [{ bundleId: 'b-found', bundleName: 'TestProjectMyBundle' }], - }); - - mockListConfigurationBundleVersions.mockResolvedValue({ - versions: [{ versionId: 'v-latest', versionCreatedAt: 1234567890 }], - }); - - mockUpdateConfigurationBundle.mockResolvedValue({ - bundleId: 'b-found', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-found', - versionId: 'v-new', - }); - - const result = await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([ - { - name: 'MyBundle', - components: { new: { type: 'inline', value: 'data' } }, - }, - ]), - existingBundles, - }); - - expect(mockListConfigurationBundles).toHaveBeenCalledWith({ region: REGION, maxResults: 100 }); - expect(mockListConfigurationBundleVersions).toHaveBeenCalledWith({ - region: REGION, - bundleId: 'b-found', - }); - expect(result.results[0]).toMatchObject({ status: 'updated', bundleId: 'b-found', versionId: 'v-new' }); - expect(result.hasErrors).toBe(false); - }); - - it('should create a new bundle when findBundleByName returns nothing after 404', async () => { - const existingBundles = { - MyBundle: { - bundleId: 'b-old', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-old', - versionId: 'v-old', - }, - }; - - mockGetConfigurationBundleVersion.mockRejectedValueOnce(new Error('404 not found')); - mockListConfigurationBundles.mockResolvedValue({ bundles: [] }); - mockCreateConfigurationBundle.mockResolvedValue({ - bundleId: 'b-new', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-new', - versionId: 'v-1', - }); - - const result = await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([ - { name: 'MyBundle', type: 'ConfigurationBundle', components: { x: { type: 'inline', value: '1' } } }, - ]), - existingBundles, - }); - - expect(mockCreateConfigurationBundle).toHaveBeenCalled(); - expect(result.results[0]).toMatchObject({ status: 'created', bundleId: 'b-new' }); - }); - }); - - describe('error handling', () => { - it('should report error status when create fails', async () => { - mockListConfigurationBundles.mockResolvedValue({ bundles: [] }); - mockCreateConfigurationBundle.mockRejectedValue(new Error('Service unavailable')); - - const result = await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([ - { name: 'MyBundle', type: 'ConfigurationBundle', components: { x: { type: 'inline', value: '1' } } }, - ]), - }); - - expect(result.results[0]).toMatchObject({ - bundleName: 'MyBundle', - status: 'error', - error: 'Service unavailable', - }); - expect(result.hasErrors).toBe(true); - }); - - it('should report error status when update fails with non-404 error', async () => { - const existingBundles = { - MyBundle: { - bundleId: 'b-123', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-123', - versionId: 'v-1', - }, - }; - - mockGetConfigurationBundleVersion.mockRejectedValue(new Error('Throttling exception')); - - const result = await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([ - { name: 'MyBundle', type: 'ConfigurationBundle', components: { x: { type: 'inline', value: '1' } } }, - ]), - existingBundles, - }); - - expect(result.results[0]).toMatchObject({ - bundleName: 'MyBundle', - status: 'error', - error: 'Throttling exception', - }); - expect(result.hasErrors).toBe(true); - // Should NOT fall through to findBundleByName - expect(mockListConfigurationBundles).not.toHaveBeenCalled(); - }); - - it('should report error when delete throws an exception', async () => { - const existingBundles = { - OrphanBundle: { - bundleId: 'b-orphan', - bundleArn: 'arn:aws:agentcore:us-west-2:123:bundle/b-orphan', - versionId: 'v-1', - }, - }; - - mockDeleteConfigurationBundle.mockRejectedValue(new Error('Network error')); - - const result = await setupConfigBundles({ - region: REGION, - projectSpec: makeProjectSpec([]), - existingBundles, - }); - - expect(result.results[0]).toMatchObject({ - bundleName: 'OrphanBundle', - status: 'error', - error: 'Network error', - }); - expect(result.hasErrors).toBe(true); - }); - }); -}); - -// ── resolveConfigBundleComponentKeys ─────────────────────────────────────── - -describe('resolveConfigBundleComponentKeys', () => { - function makeFullProjectSpec(configBundles: AgentCoreProjectSpec['configBundles'] = []): AgentCoreProjectSpec { - return { - name: 'TestProject', - version: 1, - managedBy: 'CDK' as const, - runtimes: [], - memories: [], - credentials: [], - evaluators: [], - onlineEvalConfigs: [], - agentCoreGateways: [], - policyEngines: [], - configBundles, - httpGateways: [], - datasets: [], - abTests: [], - harnesses: [], - payments: [], - }; - } - - function makeDeployedState(targetName: string, resources: Record): DeployedState { - return { - targets: { - [targetName]: { resources }, - }, - } as unknown as DeployedState; - } - - it('returns projectSpec unchanged when target has no resources', () => { - const spec = makeFullProjectSpec([ - { name: 'b1', components: { '{{runtime:my-rt}}': { configuration: { k: 'v' } } } } as any, - ]); - const deployedState = { targets: {} } as unknown as DeployedState; - - const result = resolveConfigBundleComponentKeys(spec, deployedState, 'missing-target'); - expect(result).toBe(spec); // same reference — no transformation - }); - - it('resolves {{runtime:name}} placeholder to runtime ARN', () => { - const spec = makeFullProjectSpec([ - { name: 'b1', components: { '{{runtime:my-agent}}': { configuration: { k: 'v' } } } } as any, - ]); - const deployedState = makeDeployedState('target1', { - runtimes: { 'my-agent': { runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1' } }, - }); - - const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); - const keys = Object.keys(result.configBundles[0]!.components); - expect(keys).toEqual(['arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1']); - }); - - it('resolves {{gateway:name}} placeholder to HTTP gateway ARN', () => { - const spec = makeFullProjectSpec([ - { name: 'b1', components: { '{{gateway:my-gw}}': { configuration: { k: 'v' } } } } as any, - ]); - const deployedState = makeDeployedState('target1', { - httpGateways: { 'my-gw': { gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-1' } }, - }); - - const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); - const keys = Object.keys(result.configBundles[0]!.components); - expect(keys).toEqual(['arn:aws:bedrock-agentcore:us-east-1:123:gateway/gw-1']); - }); - - it('resolves {{gateway:name}} placeholder to MCP gateway ARN', () => { - const spec = makeFullProjectSpec([ - { name: 'b1', components: { '{{gateway:my-mcp-gw}}': { configuration: { k: 'v' } } } } as any, - ]); - const deployedState = makeDeployedState('target1', { - mcp: { gateways: { 'my-mcp-gw': { gatewayArn: 'arn:mcp:gw:resolved' } } }, - }); - - const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); - const keys = Object.keys(result.configBundles[0]!.components); - expect(keys).toEqual(['arn:mcp:gw:resolved']); - }); - - it('passes through keys that are already ARNs', () => { - const spec = makeFullProjectSpec([ - { name: 'b1', components: { 'arn:existing:key': { configuration: { k: 'v' } } } } as any, - ]); - const deployedState = makeDeployedState('target1', { runtimes: {} }); - - const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); - const keys = Object.keys(result.configBundles[0]!.components); - expect(keys).toEqual(['arn:existing:key']); - }); - - it('passes through plain string keys that are not placeholders or ARNs', () => { - const spec = makeFullProjectSpec([ - { name: 'b1', components: { 'some-plain-key': { configuration: { k: 'v' } } } } as any, - ]); - const deployedState = makeDeployedState('target1', { runtimes: {} }); - - const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); - const keys = Object.keys(result.configBundles[0]!.components); - expect(keys).toEqual(['some-plain-key']); - }); - - it('throws when gateway placeholder references non-existent gateway', () => { - const spec = makeFullProjectSpec([ - { name: 'b1', components: { '{{gateway:missing}}': { configuration: {} } } } as any, - ]); - const deployedState = makeDeployedState('target1', { httpGateways: {}, mcp: { gateways: {} } }); - - expect(() => resolveConfigBundleComponentKeys(spec, deployedState, 'target1')).toThrow( - 'Config bundle references gateway "missing" but it was not found in deployed resources' - ); - }); - - it('throws when runtime placeholder references non-existent runtime', () => { - const spec = makeFullProjectSpec([ - { name: 'b1', components: { '{{runtime:missing}}': { configuration: {} } } } as any, - ]); - const deployedState = makeDeployedState('target1', { runtimes: {} }); - - expect(() => resolveConfigBundleComponentKeys(spec, deployedState, 'target1')).toThrow( - 'Config bundle references runtime "missing" but it was not found in deployed resources' - ); - }); - - it('handles projectSpec with no configBundles', () => { - const spec = makeFullProjectSpec([]); - const deployedState = makeDeployedState('target1', { runtimes: {} }); - - const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); - expect(result.configBundles).toEqual([]); - }); - - it('does not mutate the original projectSpec', () => { - const spec = makeFullProjectSpec([ - { name: 'b1', components: { '{{runtime:my-rt}}': { configuration: { k: 'v' } } } } as any, - ]); - const deployedState = makeDeployedState('target1', { - runtimes: { 'my-rt': { runtimeArn: 'arn:resolved' } }, - }); - - const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); - // Original should still have the placeholder - expect(Object.keys(spec.configBundles[0]!.components)).toEqual(['{{runtime:my-rt}}']); - // Result should have the resolved key - expect(Object.keys(result.configBundles[0]!.components)).toEqual(['arn:resolved']); - }); - - it('prefers HTTP gateway over MCP gateway when both exist with same name', () => { - const spec = makeFullProjectSpec([ - { name: 'b1', components: { '{{gateway:dupe-gw}}': { configuration: {} } } } as any, - ]); - const deployedState = makeDeployedState('target1', { - httpGateways: { 'dupe-gw': { gatewayArn: 'arn:http:gw' } }, - mcp: { gateways: { 'dupe-gw': { gatewayArn: 'arn:mcp:gw' } } }, - }); - - const result = resolveConfigBundleComponentKeys(spec, deployedState, 'target1'); - const keys = Object.keys(result.configBundles[0]!.components); - // HTTP gateway should take precedence (checked first in code) - expect(keys).toEqual(['arn:http:gw']); - }); -}); diff --git a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts deleted file mode 100644 index 17321f72d..000000000 --- a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts +++ /dev/null @@ -1,471 +0,0 @@ -import type { AgentCoreProjectSpec, DeployedResourceState, HttpGatewayDeployedState } from '../../../../schema'; -import { deleteOrphanedHttpGateways, setupHttpGateways } from '../post-deploy-http-gateways.js'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// ── Hoisted mocks ────────────────────────────────────────────────────────── - -const { - mockCreateHttpGateway, - mockCreateHttpGatewayTarget, - mockDeleteHttpGateway, - mockDeleteHttpGatewayTarget, - mockListAllHttpGateways, - mockListHttpGatewayTargets, - mockWaitForGatewayReady, - mockWaitForTargetReady, - mockGetCredentialProvider, - mockIAMSend, -} = vi.hoisted(() => ({ - mockCreateHttpGateway: vi.fn(), - mockCreateHttpGatewayTarget: vi.fn(), - mockDeleteHttpGateway: vi.fn(), - mockDeleteHttpGatewayTarget: vi.fn(), - mockListAllHttpGateways: vi.fn(), - mockListHttpGatewayTargets: vi.fn(), - mockWaitForGatewayReady: vi.fn(), - mockWaitForTargetReady: vi.fn(), - mockGetCredentialProvider: vi.fn().mockReturnValue(undefined), - mockIAMSend: vi.fn(), -})); - -vi.mock('../../../aws/agentcore-http-gateways', () => ({ - createHttpGateway: mockCreateHttpGateway, - createHttpGatewayTarget: mockCreateHttpGatewayTarget, - deleteHttpGateway: mockDeleteHttpGateway, - deleteHttpGatewayTarget: mockDeleteHttpGatewayTarget, - listAllHttpGateways: mockListAllHttpGateways, - listHttpGatewayTargets: mockListHttpGatewayTargets, - waitForGatewayReady: mockWaitForGatewayReady, - waitForTargetReady: mockWaitForTargetReady, -})); - -vi.mock('../../../aws/account', () => ({ - getCredentialProvider: mockGetCredentialProvider, -})); - -vi.mock('@aws-sdk/client-iam', () => ({ - IAMClient: class { - send = mockIAMSend; - }, - CreateRoleCommand: class { - constructor(public input: unknown) {} - }, - GetRoleCommand: class { - constructor(public input: unknown) {} - }, - PutRolePolicyCommand: class { - constructor(public input: unknown) {} - }, - DeleteRolePolicyCommand: class { - constructor(public input: unknown) {} - }, - DeleteRoleCommand: class { - constructor(public input: unknown) {} - }, -})); - -// ── Helpers ──────────────────────────────────────────────────────────────── - -function makeProjectSpec(httpGateways: AgentCoreProjectSpec['httpGateways'] = []): AgentCoreProjectSpec { - return { - name: 'TestProject', - version: 1, - managedBy: 'CDK' as const, - runtimes: [], - memories: [], - credentials: [], - evaluators: [], - onlineEvalConfigs: [], - agentCoreGateways: [], - policyEngines: [], - configBundles: [], - abTests: [], - httpGateways, - harnesses: [], - datasets: [], - payments: [], - }; -} - -const sampleHttpGateway = { - name: 'MyHttpGw', - runtimeRef: 'my-agent', - roleArn: 'arn:aws:iam::123456789012:role/ExistingRole', -}; - -const sampleDeployedResources = { - runtimes: { - 'my-agent': { - runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/rt-123', - runtimeId: 'rt-123', - }, - }, -} as unknown as DeployedResourceState; - -// ── Tests ────────────────────────────────────────────────────────────────── - -describe('setupHttpGateways', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockListAllHttpGateways.mockResolvedValue([]); - mockListHttpGatewayTargets.mockResolvedValue({ targets: [] }); - mockWaitForGatewayReady.mockResolvedValue({ gatewayId: 'gw-001', status: 'READY' }); - mockWaitForTargetReady.mockResolvedValue({}); - }); - - describe('creation', () => { - it('creates gateway + target for new spec entry', async () => { - mockCreateHttpGateway.mockResolvedValue({ - gatewayId: 'gw-001', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:httpgateway/gw-001', - }); - mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-001' }); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - deployedResources: sampleDeployedResources, - }); - - expect(result.hasErrors).toBe(false); - expect(result.results).toHaveLength(1); - expect(result.results[0]!.status).toBe('created'); - expect(result.results[0]!.gatewayId).toBe('gw-001'); - expect(result.httpGateways.MyHttpGw).toEqual( - expect.objectContaining({ - gatewayId: 'gw-001', - gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:123:httpgateway/gw-001', - targetId: 'tgt-001', - }) - ); - - expect(mockCreateHttpGateway).toHaveBeenCalledWith({ - region: 'us-east-1', - name: 'TestProject-MyHttpGw', - roleArn: 'arn:aws:iam::123456789012:role/ExistingRole', - }); - expect(mockCreateHttpGatewayTarget).toHaveBeenCalledWith({ - region: 'us-east-1', - gatewayId: 'gw-001', - targetName: 'TestProject-my-agent', - runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/rt-123', - }); - }); - - it('skips existing gateway', async () => { - const existingHttpGateways: Record = { - MyHttpGw: { - gatewayId: 'gw-existing', - gatewayArn: 'arn:httpgw:existing', - targetId: 'tgt-existing', - }, - }; - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - existingHttpGateways, - deployedResources: sampleDeployedResources, - }); - - expect(result.results[0]!.status).toBe('skipped'); - expect(result.results[0]!.gatewayId).toBe('gw-existing'); - expect(mockCreateHttpGateway).not.toHaveBeenCalled(); - expect(mockCreateHttpGatewayTarget).not.toHaveBeenCalled(); - }); - - it('finds gateway by name via list (state loss recovery)', async () => { - mockListAllHttpGateways.mockResolvedValue([ - { name: 'TestProject-MyHttpGw', gatewayId: 'gw-api', gatewayArn: 'arn:httpgw:api' }, - ]); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - deployedResources: sampleDeployedResources, - }); - - expect(result.results[0]!.status).toBe('skipped'); - expect(result.httpGateways.MyHttpGw!.gatewayId).toBe('gw-api'); - expect(mockCreateHttpGateway).not.toHaveBeenCalled(); - }); - - it('recovers state using legacy (pre-migration) gateway name when prefixed name not found', async () => { - // First call: prefixed name "TestProject-MyHttpGw" → not found - // Second call: unprefixed legacy name "MyHttpGw" → found - mockListAllHttpGateways - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ name: 'MyHttpGw', gatewayId: 'gw-legacy', gatewayArn: 'arn:httpgw:legacy' }]); - - const warnSpy = vi.spyOn(console, 'warn').mockReturnValue(undefined); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - deployedResources: sampleDeployedResources, - }); - - // findHttpGatewayByName was called twice: once for prefixed, once for unprefixed name - expect(mockListAllHttpGateways).toHaveBeenCalledTimes(2); - - // Gateway result is skipped (not created) - expect(result.results[0]!.status).toBe('skipped'); - expect(result.results[0]!.gatewayId).toBe('gw-legacy'); - expect(result.httpGateways.MyHttpGw!.gatewayId).toBe('gw-legacy'); - - // createHttpGateway was NOT called - expect(mockCreateHttpGateway).not.toHaveBeenCalled(); - - // console.warn was called with the pre-migration warning text - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('pre-migration name')); - - warnSpy.mockRestore(); - }); - - it('reports error on missing runtime ref', async () => { - const emptyDeployedResources = {} as unknown as DeployedResourceState; - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - deployedResources: emptyDeployedResources, - }); - - expect(result.hasErrors).toBe(true); - expect(result.results[0]!.status).toBe('error'); - expect(result.results[0]!.error).toContain('Runtime "my-agent" not found'); - expect(mockCreateHttpGateway).not.toHaveBeenCalled(); - }); - - it('auto-creates IAM role when roleArn not provided', async () => { - const gwWithoutRole = { ...sampleHttpGateway, roleArn: undefined }; - mockCreateHttpGateway.mockResolvedValue({ - gatewayId: 'gw-002', - gatewayArn: 'arn:httpgw:002', - }); - mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-002' }); - mockIAMSend.mockResolvedValue({ Role: { Arn: 'arn:aws:iam::123:role/AutoRole' } }); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([gwWithoutRole]), - deployedResources: sampleDeployedResources, - }); - - expect(result.results[0]!.status).toBe('created'); - expect(result.httpGateways.MyHttpGw!.roleCreatedByCli).toBe(true); - expect(mockIAMSend).toHaveBeenCalled(); - - // Verify CreateRoleCommand was sent with correct trust policy - const createRoleCall = mockIAMSend.mock.calls[0]![0]; - const trustPolicy = JSON.parse(createRoleCall.input.AssumeRolePolicyDocument); - expect(trustPolicy.Statement[0].Principal.Service).toBe('bedrock-agentcore.amazonaws.com'); - - // Verify PutRolePolicyCommand was sent with correct inline policy actions - const putPolicyCall = mockIAMSend.mock.calls[1]![0]; - const inlinePolicy = JSON.parse(putPolicyCall.input.PolicyDocument); - const actions = inlinePolicy.Statement[0].Action; - expect(actions).toContain('bedrock-agentcore:InvokeRuntime'); - expect(actions).toContain('bedrock-agentcore:InvokeAgent'); - expect(actions).toContain('bedrock-agentcore:InvokeAgentRuntime'); - expect(inlinePolicy.Statement[0].Resource).toBe('*'); - }); - - it('rollback on target creation failure', async () => { - mockCreateHttpGateway.mockResolvedValue({ - gatewayId: 'gw-rollback', - gatewayArn: 'arn:httpgw:rollback', - }); - mockCreateHttpGatewayTarget.mockRejectedValue(new Error('Target creation failed')); - mockDeleteHttpGateway.mockResolvedValue({ success: true }); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - deployedResources: sampleDeployedResources, - }); - - expect(result.hasErrors).toBe(true); - expect(result.results[0]!.status).toBe('error'); - expect(result.results[0]!.error).toContain('Target creation failed'); - expect(result.results[0]!.error).toContain('gateway rolled back'); - - // Verify rollback: deleteHttpGateway was called - expect(mockDeleteHttpGateway).toHaveBeenCalledWith({ - region: 'us-east-1', - gatewayId: 'gw-rollback', - }); - }); - }); - - describe('deletion (reconciliation)', () => { - it('deletes orphaned gateway not in project spec', async () => { - mockDeleteHttpGateway.mockResolvedValue({ success: true }); - mockDeleteHttpGatewayTarget.mockResolvedValue({ success: true }); - - const result = await deleteOrphanedHttpGateways({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingHttpGateways: { - RemovedGw: { - gatewayId: 'gw-old', - gatewayArn: 'arn:httpgw:old', - targetId: 'tgt-old', - }, - }, - }); - - expect(mockDeleteHttpGatewayTarget).toHaveBeenCalledWith({ - region: 'us-east-1', - gatewayId: 'gw-old', - targetId: 'tgt-old', - }); - expect(mockDeleteHttpGateway).toHaveBeenCalledWith({ - region: 'us-east-1', - gatewayId: 'gw-old', - }); - expect(result.results[0]!.status).toBe('deleted'); - }); - - it('cleans up auto-created IAM role on deletion', async () => { - mockDeleteHttpGateway.mockResolvedValue({ success: true }); - mockIAMSend.mockResolvedValue({}); - - await deleteOrphanedHttpGateways({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingHttpGateways: { - RemovedGw: { - gatewayId: 'gw-old', - gatewayArn: 'arn:httpgw:old', - roleArn: 'arn:aws:iam::123:role/AutoCreatedRole', - roleCreatedByCli: true, - }, - }, - }); - - // Should have called delete policy + delete role - expect(mockIAMSend).toHaveBeenCalledTimes(2); - - // Verify first call is DeleteRolePolicyCommand - const firstCall = mockIAMSend.mock.calls[0]![0]; - expect(firstCall.input).toEqual( - expect.objectContaining({ RoleName: 'AutoCreatedRole', PolicyName: expect.any(String) }) - ); - - // Verify second call is DeleteRoleCommand - const secondCall = mockIAMSend.mock.calls[1]![0]; - expect(secondCall.input).toEqual(expect.objectContaining({ RoleName: 'AutoCreatedRole' })); - }); - - it('reports error when deletion fails', async () => { - mockDeleteHttpGateway.mockRejectedValue(new Error('delete failed')); - - const result = await deleteOrphanedHttpGateways({ - region: 'us-east-1', - projectSpec: makeProjectSpec([]), - existingHttpGateways: { - FailGw: { gatewayId: 'gw-fail', gatewayArn: 'arn:httpgw:fail' }, - }, - }); - - expect(result.hasErrors).toBe(true); - expect(result.results[0]!.status).toBe('error'); - expect(result.results[0]!.error).toBe('delete failed'); - }); - }); - - describe('edge cases', () => { - it('proceeds with creation when listHttpGateways fails', async () => { - mockListAllHttpGateways.mockRejectedValue(new Error('API unavailable')); - mockCreateHttpGateway.mockResolvedValue({ - gatewayId: 'gw-new', - gatewayArn: 'arn:httpgw:new', - }); - mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-new' }); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - deployedResources: sampleDeployedResources, - }); - - expect(result.results[0]!.status).toBe('created'); - expect(mockCreateHttpGateway).toHaveBeenCalled(); - }); - - it('uses provided roleArn without creating IAM role', async () => { - mockCreateHttpGateway.mockResolvedValue({ - gatewayId: 'gw-003', - gatewayArn: 'arn:httpgw:003', - }); - mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-003' }); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([sampleHttpGateway]), - deployedResources: sampleDeployedResources, - }); - - expect(result.results[0]!.status).toBe('created'); - expect(result.httpGateways.MyHttpGw!.roleCreatedByCli).toBe(false); - expect(mockIAMSend).not.toHaveBeenCalled(); - }); - }); - - describe('mixed operations', () => { - it('creates new and skips existing (orphan deletion is a separate pass)', async () => { - const newGw = { ...sampleHttpGateway, name: 'NewGw' }; - const keptGw = { ...sampleHttpGateway, name: 'KeptGw' }; - - mockCreateHttpGateway.mockResolvedValue({ - gatewayId: 'gw-new', - gatewayArn: 'arn:httpgw:new', - }); - mockCreateHttpGatewayTarget.mockResolvedValue({ targetId: 'tgt-new' }); - mockDeleteHttpGateway.mockResolvedValue({ success: true }); - - const result = await setupHttpGateways({ - region: 'us-east-1', - projectName: 'TestProject', - projectSpec: makeProjectSpec([newGw, keptGw]), - existingHttpGateways: { - KeptGw: { gatewayId: 'gw-kept', gatewayArn: 'arn:httpgw:kept' }, - OrphanGw: { gatewayId: 'gw-orphan', gatewayArn: 'arn:httpgw:orphan' }, - }, - deployedResources: sampleDeployedResources, - }); - - expect(result.results).toHaveLength(2); - const statuses = result.results.map(r => `${r.gatewayName}:${r.status}`); - expect(statuses).toContain('NewGw:created'); - expect(statuses).toContain('KeptGw:skipped'); - }); - - it('deleteOrphanedHttpGateways removes orphans separately', async () => { - mockDeleteHttpGateway.mockResolvedValue({ success: true }); - - const result = await deleteOrphanedHttpGateways({ - region: 'us-east-1', - projectSpec: makeProjectSpec([{ ...sampleHttpGateway, name: 'KeptGw' }]), - existingHttpGateways: { - KeptGw: { gatewayId: 'gw-kept', gatewayArn: 'arn:httpgw:kept' }, - OrphanGw: { gatewayId: 'gw-orphan', gatewayArn: 'arn:httpgw:orphan' }, - }, - }); - - expect(result.results).toHaveLength(1); - expect(result.results[0]!.gatewayName).toBe('OrphanGw'); - expect(result.results[0]!.status).toBe('deleted'); - }); - }); -}); diff --git a/src/cli/operations/deploy/__tests__/post-deploy-knowledge-bases.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-knowledge-bases.test.ts new file mode 100644 index 000000000..934ff6a58 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/post-deploy-knowledge-bases.test.ts @@ -0,0 +1,165 @@ +import * as ingest from '../../ingest'; +import { autoIngestKnowledgeBases, computeSourcesHash } from '../post-deploy-knowledge-bases'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../ingest'); + +const kbWithSources = (name: string, uris: string[]) => + ({ + type: 'AgentCoreKnowledgeBase', + name, + dataSources: uris.map(uri => ({ type: 'S3' as const, uri })), + }) as never; + +const deployedKb = (id: string, dsIds: string[], sourcesHash?: string) => ({ + knowledgeBaseId: id, + knowledgeBaseArn: `arn:aws:bedrock:us-west-2:0:knowledge-base/${id}`, + dataSources: dsIds.map((dsId, idx) => ({ + dataSourceId: dsId, + uri: `s3://b/ds${idx}/`, + })), + ...(sourcesHash && { sourcesHash }), +}); + +const stubDeployedState = () => ({ targets: { default: { resources: { knowledgeBases: {} } } } }) as never; + +describe('computeSourcesHash', () => { + it('produces a stable hash for identical URI lists', () => { + const kb1 = kbWithSources('a', ['s3://b/x/', 's3://b/y/']); + const kb2 = kbWithSources('a', ['s3://b/x/', 's3://b/y/']); + expect(computeSourcesHash(kb1)).toBe(computeSourcesHash(kb2)); + }); + + it('produces different hashes when a URI changes', () => { + const kb1 = kbWithSources('a', ['s3://b/x/']); + const kb2 = kbWithSources('a', ['s3://b/y/']); + expect(computeSourcesHash(kb1)).not.toBe(computeSourcesHash(kb2)); + }); + + it('produces different hashes when URI order changes', () => { + const kb1 = kbWithSources('a', ['s3://b/x/', 's3://b/y/']); + const kb2 = kbWithSources('a', ['s3://b/y/', 's3://b/x/']); + expect(computeSourcesHash(kb1)).not.toBe(computeSourcesHash(kb2)); + }); +}); + +describe('autoIngestKnowledgeBases', () => { + beforeEach(() => vi.mocked(ingest.runKbIngestionByName).mockReset()); + afterEach(() => vi.restoreAllMocks()); + + it('starts ingestion for a KB with no prior hash (first deploy)', async () => { + vi.mocked(ingest.runKbIngestionByName).mockResolvedValueOnce({ + success: true, + startedJobs: [{ dataSourceId: 'DS1', uri: 's3://b/ds0/', ingestionJobId: 'IJ-1' }], + } as never); + + const result = await autoIngestKnowledgeBases({ + region: 'us-west-2', + knowledgeBases: [kbWithSources('docs', ['s3://b/ds0/'])], + deployedKnowledgeBases: { docs: deployedKb('KB1', ['DS1']) }, + previousKnowledgeBases: undefined, + targetName: 'default', + deployedState: stubDeployedState(), + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + const entry = result.results[0]!; + expect(entry.status).toBe('started'); + expect(entry.startedJobCount).toBe(1); + expect(entry.newSourcesHash).toBeTruthy(); + }); + + it('skips ingestion when sourcesHash matches the prior deploy', async () => { + const kb = kbWithSources('docs', ['s3://b/ds0/']); + const priorHash = computeSourcesHash(kb); + + const result = await autoIngestKnowledgeBases({ + region: 'us-west-2', + knowledgeBases: [kb], + deployedKnowledgeBases: { docs: deployedKb('KB1', ['DS1']) }, + previousKnowledgeBases: { docs: deployedKb('KB1', ['DS1'], priorHash) }, + targetName: 'default', + deployedState: stubDeployedState(), + }); + + expect(result.hasErrors).toBe(false); + expect(result.results[0]?.status).toBe('skipped'); + expect(result.results[0]?.reason).toMatch(/no changes/i); + expect(ingest.runKbIngestionByName).not.toHaveBeenCalled(); + }); + + it('starts ingestion when sourcesHash differs from prior', async () => { + vi.mocked(ingest.runKbIngestionByName).mockResolvedValueOnce({ + success: true, + startedJobs: [{ dataSourceId: 'DS1', uri: 's3://b/ds0/', ingestionJobId: 'IJ-1' }], + } as never); + + const result = await autoIngestKnowledgeBases({ + region: 'us-west-2', + knowledgeBases: [kbWithSources('docs', ['s3://b/ds0/'])], + deployedKnowledgeBases: { docs: deployedKb('KB1', ['DS1']) }, + previousKnowledgeBases: { docs: deployedKb('KB1', ['DS1'], 'old-hash') }, + targetName: 'default', + deployedState: stubDeployedState(), + }); + + expect(result.results[0]?.status).toBe('started'); + expect(ingest.runKbIngestionByName).toHaveBeenCalledTimes(1); + }); + + it('records errors but does not abort other KBs', async () => { + vi.mocked(ingest.runKbIngestionByName) + .mockResolvedValueOnce({ success: false, error: new Error('Throttled') } as never) + .mockResolvedValueOnce({ + success: true, + startedJobs: [{ dataSourceId: 'DS2', uri: 's3://b/ds0/', ingestionJobId: 'IJ-2' }], + } as never); + + const result = await autoIngestKnowledgeBases({ + region: 'us-west-2', + knowledgeBases: [kbWithSources('a', ['s3://b/ds0/']), kbWithSources('b', ['s3://b/ds0/'])], + deployedKnowledgeBases: { + a: deployedKb('KB1', ['DS1']), + b: deployedKb('KB2', ['DS2']), + }, + previousKnowledgeBases: undefined, + targetName: 'default', + deployedState: stubDeployedState(), + }); + + expect(result.hasErrors).toBe(true); + expect(result.results[0]?.status).toBe('error'); + expect(result.results[0]?.error).toMatch(/Throttled/); + expect(result.results[1]?.status).toBe('started'); + }); + + it('skips a KB that has no data sources recorded yet', async () => { + const result = await autoIngestKnowledgeBases({ + region: 'us-west-2', + knowledgeBases: [kbWithSources('docs', ['s3://b/ds0/'])], + deployedKnowledgeBases: { docs: deployedKb('KB1', []) }, + previousKnowledgeBases: undefined, + targetName: 'default', + deployedState: stubDeployedState(), + }); + + expect(result.results[0]?.status).toBe('skipped'); + expect(result.results[0]?.reason).toMatch(/no data sources/i); + expect(ingest.runKbIngestionByName).not.toHaveBeenCalled(); + }); + + it('skips a KB that is missing from deployed state (CFN outputs missing)', async () => { + const result = await autoIngestKnowledgeBases({ + region: 'us-west-2', + knowledgeBases: [kbWithSources('docs', ['s3://b/ds0/'])], + deployedKnowledgeBases: {}, + previousKnowledgeBases: undefined, + targetName: 'default', + deployedState: stubDeployedState(), + }); + + expect(result.results[0]?.status).toBe('skipped'); + expect(result.results[0]?.reason).toMatch(/not present in deployed state/i); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts index 58cfc0f12..dbe675c0b 100644 --- a/src/cli/operations/deploy/__tests__/preflight.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight.test.ts @@ -88,10 +88,35 @@ describe('validateProject', () => { mockReadDeployedState.mockRejectedValue(new Error('No deployed state')); await expect(validateProject()).rejects.toThrow( - 'No resources defined in project. Add at least one resource (agent, memory, evaluator, or gateway) before deploying.' + 'No resources defined in project. Add at least one resource (agent, memory, knowledge base, evaluator, or gateway) before deploying.' ); }); + it('allows deploy when only a knowledge base is defined (no agents or gateways)', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + runtimes: [], + memories: [], + knowledgeBases: [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/' }], + }, + ], + agentCoreGateways: [], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + + expect(result.projectSpec.name).toBe('test-project'); + expect(result.isTeardownDeploy).toBe(false); + }); + it('allows deploy when memories exist but no agents or gateways', async () => { mockRequireConfigRoot.mockReturnValue('/project/agentcore'); mockValidate.mockReturnValue(undefined); @@ -117,6 +142,7 @@ describe('validateProject', () => { name: 'test-project', runtimes: [], memories: [], + knowledgeBases: [], datasets: [ { name: 'test-dataset', @@ -152,29 +178,6 @@ describe('validateProject', () => { expect(result.isTeardownDeploy).toBe(false); }); - it('rejects gateway target name that exceeds 48 chars when prefixed with project name', async () => { - mockRequireConfigRoot.mockReturnValue('/project/agentcore'); - mockValidate.mockReturnValue(undefined); - // projectName "myproject" (9) + "-" (1) + targetName (39) = 49 > 48 - mockReadProjectSpec.mockResolvedValue({ - name: 'myproject', - runtimes: [], - httpGateways: [ - { - name: 'gw', - targets: [{ name: 'a'.repeat(39), runtimeRef: 'rt', qualifier: 'DEFAULT' }], - }, - ], - agentCoreGateways: [{ name: 'gw' }], - }); - mockReadAWSDeploymentTargets.mockResolvedValue([]); - mockValidateAwsCredentials.mockResolvedValue(undefined); - - await expect(validateProject()).rejects.toThrow( - 'HTTP gateway target "' + 'a'.repeat(39) + '" in gateway "gw" would exceed the 48-character AWS limit' - ); - }); - it('accepts gateway target name within 48 chars when prefixed with project name', async () => { mockRequireConfigRoot.mockReturnValue('/project/agentcore'); mockValidate.mockReturnValue(undefined); @@ -182,12 +185,6 @@ describe('validateProject', () => { mockReadProjectSpec.mockResolvedValue({ name: 'myproject', runtimes: [], - httpGateways: [ - { - name: 'gw', - targets: [{ name: 'a'.repeat(38), runtimeRef: 'rt', qualifier: 'DEFAULT' }], - }, - ], agentCoreGateways: [{ name: 'gw' }], }); mockReadAWSDeploymentTargets.mockResolvedValue([]); diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts deleted file mode 100644 index 4254d4581..000000000 --- a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts +++ /dev/null @@ -1,466 +0,0 @@ -import { createHarness, deleteHarness, getHarness, updateHarness } from '../../../../../aws/agentcore-harness'; -import { AgentCoreApiError } from '../../../../../aws/api-client'; -import type { ImperativeDeployContext } from '../../types'; -import { HarnessDeployer } from '../harness-deployer'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('fs/promises', () => ({ - readFile: vi.fn().mockImplementation((path: string) => { - if (path.includes('harness.json')) { - return Promise.resolve( - JSON.stringify({ - name: 'my_harness', - model: { provider: 'bedrock', modelId: 'anthropic.claude-3-5-sonnet' }, - tools: [], - skills: [], - }) - ); - } - if (path.includes('system-prompt.md')) return Promise.resolve('You are helpful.'); - return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); - }), -})); - -vi.mock('../harness-mapper', () => ({ - mapHarnessSpecToCreateOptions: vi.fn().mockResolvedValue({ - harnessName: 'proj_my-harness', - region: 'us-east-1', - executionRoleArn: 'arn:aws:iam::111:role/HarnessRole', - model: { bedrockModelConfig: { modelId: 'anthropic.claude-3-5-sonnet' } }, - systemPrompt: [{ text: 'You are helpful.' }], - }), -})); - -vi.mock('../../../../../aws/agentcore-harness', () => ({ - createHarness: vi.fn().mockResolvedValue({ - harness: { - harnessId: 'h-123', - arn: 'arn:aws:bedrock:us-east-1:111:harness/h-123', - status: 'READY', - environment: { agentCoreRuntimeEnvironment: { agentRuntimeArn: 'arn:runtime' } }, - }, - }), - updateHarness: vi.fn().mockResolvedValue({ - harness: { - harnessId: 'h-existing', - arn: 'arn:aws:bedrock:us-east-1:111:harness/h-existing', - status: 'READY', - environment: { agentCoreRuntimeEnvironment: { agentRuntimeArn: 'arn:runtime' } }, - }, - }), - deleteHarness: vi.fn().mockResolvedValue({}), - getHarness: vi.fn().mockResolvedValue({ - harness: { - harnessId: 'h-123', - arn: 'arn:aws:bedrock:us-east-1:111:harness/h-123', - status: 'READY', - environment: { agentCoreRuntimeEnvironment: { agentRuntimeArn: 'arn:runtime' } }, - }, - }), -})); - -function makeContext(overrides: Partial = {}): ImperativeDeployContext { - return { - projectSpec: { - name: 'proj', - harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], - } as any, - target: { name: 'dev', region: 'us-east-1' } as any, - configIO: { getConfigRoot: () => '/project/agentcore' } as any, - deployedState: { targets: {} } as any, - cdkOutputs: { ApplicationHarnessMyHarnessRoleArnOutput123: 'arn:aws:iam::111:role/HarnessRole' }, - ...overrides, - }; -} - -describe('HarnessDeployer', () => { - let deployer: HarnessDeployer; - - beforeEach(() => { - deployer = new HarnessDeployer(); - vi.clearAllMocks(); - }); - - describe('shouldRun', () => { - it('returns true when project has harnesses', () => { - expect(deployer.shouldRun(makeContext())).toBe(true); - }); - - it('returns true when deployed state has harnesses', () => { - const ctx = makeContext({ - projectSpec: { name: 'proj', harnesses: [] } as any, - deployedState: { - targets: { dev: { resources: { harnesses: { old: { harnessId: 'h-old' } } } } }, - } as any, - }); - expect(deployer.shouldRun(ctx)).toBe(true); - }); - - it('returns false when no harnesses anywhere', () => { - const ctx = makeContext({ - projectSpec: { name: 'proj' } as any, - deployedState: { targets: {} } as any, - }); - expect(deployer.shouldRun(ctx)).toBe(false); - }); - }); - - describe('deploy - create path', () => { - it('calls createHarness and returns state on success', async () => { - const result = await deployer.deploy(makeContext()); - expect(result.success).toBe(true); - expect(createHarness).toHaveBeenCalled(); - expect(result.state!.my_harness).toMatchObject({ - harnessId: 'h-123', - status: 'READY', - }); - }); - - it('throws when harness enters FAILED state after create', async () => { - vi.mocked(createHarness).mockResolvedValueOnce({ - harness: { harnessId: 'h-fail', arn: 'arn:fail', status: 'CREATING' }, - } as any); - vi.mocked(getHarness).mockResolvedValueOnce({ - harness: { harnessId: 'h-fail', arn: 'arn:fail', status: 'FAILED' }, - } as any); - - const result = await deployer.deploy(makeContext()); - expect(result.success).toBe(false); - expect(result.error).toContain('FAILED state'); - }); - }); - - describe('deploy - update path', () => { - it('calls updateHarness when existing harness has different configHash', async () => { - const ctx = makeContext({ - deployedState: { - targets: { - dev: { - resources: { - harnesses: { - my_harness: { - harnessId: 'h-existing', - configHash: 'old-hash', - harnessArn: 'arn:old', - roleArn: 'arn:role', - status: 'READY', - }, - }, - }, - }, - }, - } as any, - }); - - const result = await deployer.deploy(ctx); - expect(result.success).toBe(true); - expect(updateHarness).toHaveBeenCalled(); - expect(createHarness).not.toHaveBeenCalled(); - }); - }); - - describe('deploy - skip path', () => { - it('skips when configHash matches', async () => { - // We need to compute the actual hash. Instead, mock readFile to produce deterministic content - // and set the deployed hash to match. Easiest: just set configHash to what will be computed. - // Since we can't easily predict the hash, test the logic by verifying no API calls. - const ctx = makeContext({ - deployedState: { - targets: { - dev: { - resources: { - harnesses: { - my_harness: { - harnessId: 'h-existing', - configHash: 'WILL_NOT_MATCH', - harnessArn: 'arn:x', - roleArn: 'arn:role', - status: 'READY', - }, - }, - }, - }, - }, - } as any, - }); - - // To truly test skip, we'd need to know the hash. Let's just verify that when - // configHash matches, it skips. We'll run once to get the hash, then use it. - const firstResult = await deployer.deploy(ctx); - // It will have updated because hash doesn't match - expect(updateHarness).toHaveBeenCalledTimes(1); - - // Now use the actual computed hash - vi.clearAllMocks(); - const computedHash = firstResult.state!.my_harness!.configHash; - const ctx2 = makeContext({ - deployedState: { - targets: { - dev: { - resources: { - harnesses: { - my_harness: { - harnessId: 'h-existing', - configHash: computedHash, - harnessArn: 'arn:x', - roleArn: 'arn:role', - status: 'READY', - }, - }, - }, - }, - }, - } as any, - }); - - const result = await deployer.deploy(ctx2); - expect(result.success).toBe(true); - expect(createHarness).not.toHaveBeenCalled(); - expect(updateHarness).not.toHaveBeenCalled(); - expect(result.notes).toContain('Harness "my_harness" unchanged, skipped'); - }); - }); - - describe('deploy - delete orphaned harnesses', () => { - it('deletes harnesses not in project spec', async () => { - const ctx = makeContext({ - deployedState: { - targets: { - dev: { - resources: { - harnesses: { - 'removed-harness': { - harnessId: 'h-removed', - configHash: 'x', - harnessArn: 'arn:r', - roleArn: 'arn:role', - status: 'READY', - }, - }, - }, - }, - }, - } as any, - }); - - const result = await deployer.deploy(ctx); - expect(result.success).toBe(true); - expect(deleteHarness).toHaveBeenCalledWith({ region: 'us-east-1', harnessId: 'h-removed' }); - expect(result.state!['removed-harness']).toBeUndefined(); - }); - }); - - describe('deploy - role resolution', () => { - it('fails when CDK outputs missing role ARN', async () => { - const ctx = makeContext({ cdkOutputs: {} }); - const result = await deployer.deploy(ctx); - expect(result.success).toBe(false); - expect(result.error).toContain('Could not find role ARN'); - }); - - it('resolves role from RoleRoleArn output key pattern', async () => { - const ctx = makeContext({ - cdkOutputs: { ApplicationHarnessMyHarnessRoleArnSomeSuffix: 'arn:aws:iam::111:role/NewRole' }, - }); - const result = await deployer.deploy(ctx); - expect(result.success).toBe(true); - }); - }); - - describe('deploy - retry logic', () => { - it('retries on role validation error then succeeds', async () => { - const roleError = new AgentCoreApiError(400, 'Role validation failed for the given role'); - vi.mocked(createHarness) - .mockRejectedValueOnce(roleError) - .mockResolvedValueOnce({ - harness: { harnessId: 'h-retry', arn: 'arn:retry', status: 'READY', environment: {} }, - } as any); - - const result = await deployer.deploy(makeContext()); - expect(result.success).toBe(true); - expect(createHarness).toHaveBeenCalledTimes(2); - }, 30_000); - - it('throws non-role-validation errors immediately', async () => { - vi.mocked(createHarness).mockRejectedValueOnce(new Error('Network failure')); - - const result = await deployer.deploy(makeContext()); - expect(result.success).toBe(false); - expect(result.error).toContain('Network failure'); - expect(createHarness).toHaveBeenCalledTimes(1); - }); - }); - - describe('deploy - polling (waitForReady)', () => { - it('polls getHarness until READY', async () => { - vi.mocked(createHarness).mockResolvedValueOnce({ - harness: { harnessId: 'h-poll', arn: 'arn:poll', status: 'CREATING' }, - } as any); - vi.mocked(getHarness) - .mockResolvedValueOnce({ harness: { harnessId: 'h-poll', arn: 'arn:poll', status: 'CREATING' } } as any) - .mockResolvedValueOnce({ - harness: { - harnessId: 'h-poll', - arn: 'arn:poll', - status: 'READY', - environment: { agentCoreRuntimeEnvironment: { agentRuntimeArn: 'arn:rt' } }, - }, - } as any); - - const result = await deployer.deploy(makeContext()); - expect(result.success).toBe(true); - expect(getHarness).toHaveBeenCalledTimes(2); - }); - }); - - describe('memorySpec resolution', () => { - const ROLE_ARN = 'arn:aws:iam::123456789012:role/HarnessRole'; - const MEMORY_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123'; - const CDK_OUTPUTS = { ApplicationHarnessMyHarnessRoleArnOutput123: ROLE_ARN }; - const READY_HARNESS = { - harnessId: 'h-new', - harnessName: 'my_harness', - arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/h-new', - status: 'READY' as const, - executionRoleArn: ROLE_ARN, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - }; - - const HARNESS_SPEC_WITH_MEMORY_ARN_JSON = JSON.stringify({ - name: 'my_harness', - model: { provider: 'bedrock', modelId: 'anthropic.claude-3-sonnet-20240229-v1:0' }, - tools: [], - skills: [], - memory: { arn: MEMORY_ARN }, - }); - - it('resolves memorySpec by deployed ARN when memory.name is absent', async () => { - const { readFile: mockedReadFile } = await import('fs/promises'); - const { mapHarnessSpecToCreateOptions: mockedMapHarness } = await import('../harness-mapper'); - - vi.mocked(mockedReadFile) - .mockResolvedValueOnce(HARNESS_SPEC_WITH_MEMORY_ARN_JSON as any) - .mockRejectedValueOnce(new Error('ENOENT')); - vi.mocked(mockedMapHarness).mockResolvedValueOnce({ - region: 'us-east-1', - harnessName: 'my_harness', - executionRoleArn: ROLE_ARN, - } as any); - vi.mocked(createHarness).mockResolvedValueOnce({ - harness: READY_HARNESS, - } as any); - - const ctx = makeContext({ - projectSpec: { - name: 'proj', - harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], - memories: [ - { - name: 'my_memory', - eventExpiryDuration: 30, - strategies: [{ type: 'SEMANTIC', namespaces: ['/users/{actorId}/facts'] }], - }, - ], - } as any, - deployedState: { - targets: { - dev: { - resources: { - memories: { my_memory: { memoryId: 'mem-123', memoryArn: MEMORY_ARN } }, - }, - }, - }, - } as any, - cdkOutputs: CDK_OUTPUTS, - }); - - await deployer.deploy(ctx); - - expect(mockedMapHarness).toHaveBeenCalledWith( - expect.objectContaining({ - memorySpec: { - name: 'my_memory', - eventExpiryDuration: 30, - strategies: [{ type: 'SEMANTIC', namespaces: ['/users/{actorId}/facts'] }], - }, - }) - ); - }); - - it('returns undefined memorySpec for a fully external ARN not in deployedResources', async () => { - const { readFile: mockedReadFile } = await import('fs/promises'); - const { mapHarnessSpecToCreateOptions: mockedMapHarness } = await import('../harness-mapper'); - - vi.mocked(mockedReadFile) - .mockResolvedValueOnce(HARNESS_SPEC_WITH_MEMORY_ARN_JSON as any) - .mockRejectedValueOnce(new Error('ENOENT')); - vi.mocked(mockedMapHarness).mockResolvedValueOnce({ - region: 'us-east-1', - harnessName: 'my_harness', - executionRoleArn: ROLE_ARN, - } as any); - vi.mocked(createHarness).mockResolvedValueOnce({ - harness: READY_HARNESS, - } as any); - - const ctx = makeContext({ - projectSpec: { - name: 'proj', - harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }], - memories: [], - } as any, - cdkOutputs: CDK_OUTPUTS, - }); - - await deployer.deploy(ctx); - - expect(mockedMapHarness).toHaveBeenCalledWith(expect.objectContaining({ memorySpec: undefined })); - }); - }); - - describe('teardown', () => { - it('deletes all deployed harnesses', async () => { - const ctx = makeContext({ - deployedState: { - targets: { - dev: { - resources: { - harnesses: { - h1: { harnessId: 'id-1', configHash: 'x', harnessArn: 'arn:1', roleArn: 'arn:r', status: 'READY' }, - h2: { harnessId: 'id-2', configHash: 'y', harnessArn: 'arn:2', roleArn: 'arn:r', status: 'READY' }, - }, - }, - }, - }, - } as any, - }); - - const result = await deployer.teardown(ctx); - expect(result.success).toBe(true); - expect(deleteHarness).toHaveBeenCalledTimes(2); - expect(result.state).toEqual({}); - }); - - it('returns error if delete fails', async () => { - vi.mocked(deleteHarness).mockRejectedValueOnce(new Error('Access denied')); - const ctx = makeContext({ - deployedState: { - targets: { - dev: { - resources: { - harnesses: { - h1: { harnessId: 'id-1', configHash: 'x', harnessArn: 'arn:1', roleArn: 'arn:r', status: 'READY' }, - }, - }, - }, - }, - } as any, - }); - - const result = await deployer.teardown(ctx); - expect(result.success).toBe(false); - expect(result.error).toContain('Access denied'); - }); - }); -}); diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts deleted file mode 100644 index bd9ec876f..000000000 --- a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts +++ /dev/null @@ -1,753 +0,0 @@ -import type { DeployedResourceState, Memory } from '../../../../../../schema'; -import type { MapHarnessOptions } from '../harness-mapper'; -import { mapHarnessSpecToCreateOptions } from '../harness-mapper'; -import { describe, expect, it, vi } from 'vitest'; - -vi.mock('fs/promises', () => ({ - readFile: vi.fn().mockImplementation((path: string) => { - if (path.includes('system-prompt.md')) return Promise.resolve('You are helpful.'); - if (path.includes('custom-prompt.md')) return Promise.resolve('Custom prompt content.'); - return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); - }), - stat: vi.fn().mockImplementation((path: string) => { - if (path.includes('too-large.md')) return Promise.resolve({ size: 2 * 1024 * 1024 }); - return Promise.resolve({ size: 100 }); - }), -})); - -function baseOptions(overrides: Partial = {}): MapHarnessOptions { - return { - harnessSpec: { - name: 'test-harness', - model: { provider: 'bedrock', modelId: 'anthropic.claude-3-5-sonnet' }, - tools: [], - skills: [], - } as any, - harnessDir: '/project/harnesses/test-harness', - executionRoleArn: 'arn:aws:iam::111:role/HarnessRole', - region: 'us-east-1', - projectName: 'my-project', - ...overrides, - }; -} - -describe('mapHarnessSpecToCreateOptions', () => { - describe('basic mapping', () => { - it('sets harnessName as projectName_specName', async () => { - const result = await mapHarnessSpecToCreateOptions(baseOptions()); - expect(result.harnessName).toBe('my-project_test-harness'); - }); - - it('passes region and executionRoleArn', async () => { - const result = await mapHarnessSpecToCreateOptions(baseOptions()); - expect(result.region).toBe('us-east-1'); - expect(result.executionRoleArn).toBe('arn:aws:iam::111:role/HarnessRole'); - }); - }); - - describe('model mapping', () => { - it('maps bedrock provider', async () => { - const result = await mapHarnessSpecToCreateOptions(baseOptions()); - expect(result.model).toEqual({ - bedrockModelConfig: { modelId: 'anthropic.claude-3-5-sonnet' }, - }); - }); - - it('maps open_ai provider with apiKeyArn', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'oai', - model: { - provider: 'open_ai', - modelId: 'gpt-4o', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:111:secret:key', - }, - tools: [], - skills: [], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.model).toEqual({ - openAiModelConfig: { modelId: 'gpt-4o', apiKeyArn: 'arn:aws:secretsmanager:us-east-1:111:secret:key' }, - }); - }); - - it('maps gemini provider with topK', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'gem', - model: { provider: 'gemini', modelId: 'gemini-2.0-flash', topK: 0.5 }, - tools: [], - skills: [], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.model).toEqual({ - geminiModelConfig: { modelId: 'gemini-2.0-flash', topK: 0.5 }, - }); - }); - - it('maps bedrock with apiFormat responses', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'openai.gpt-oss-120b', apiFormat: 'responses' }, - tools: [], - skills: [], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.model).toEqual({ - bedrockModelConfig: { modelId: 'openai.gpt-oss-120b', apiFormat: 'responses' }, - }); - }); - - it('maps bedrock with apiFormat chat_completions', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'openai.gpt-oss-120b', apiFormat: 'chat_completions' }, - tools: [], - skills: [], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.model).toEqual({ - bedrockModelConfig: { modelId: 'openai.gpt-oss-120b', apiFormat: 'chat_completions' }, - }); - }); - - it('omits apiFormat when converse_stream (default)', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude', apiFormat: 'converse_stream' }, - tools: [], - skills: [], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.model).toEqual({ - bedrockModelConfig: { modelId: 'claude' }, - }); - }); - - it('maps open_ai with apiFormat chat_completions', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { - provider: 'open_ai', - modelId: 'gpt-5', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', - apiFormat: 'chat_completions', - }, - tools: [], - skills: [], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.model).toEqual({ - openAiModelConfig: { - modelId: 'gpt-5', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', - apiFormat: 'chat_completions', - }, - }); - }); - - it('omits apiFormat for open_ai when responses (default)', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { - provider: 'open_ai', - modelId: 'gpt-5', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key', - apiFormat: 'responses', - }, - tools: [], - skills: [], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.model).toEqual({ - openAiModelConfig: { modelId: 'gpt-5', apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123:secret:key' }, - }); - }); - - it('includes optional model params when set', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude', temperature: 0.7, topP: 0.9, maxTokens: 2048 }, - tools: [], - skills: [], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.model).toEqual({ - bedrockModelConfig: { modelId: 'claude', temperature: 0.7, topP: 0.9, maxTokens: 2048 }, - }); - }); - }); - - describe('system prompt', () => { - it('auto-discovers system-prompt.md when no systemPrompt in spec', async () => { - const result = await mapHarnessSpecToCreateOptions(baseOptions()); - expect(result.systemPrompt).toEqual([{ text: 'You are helpful.' }]); - }); - - it('loads from file path when systemPrompt is a relative path', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - systemPrompt: './custom-prompt.md', - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.systemPrompt).toEqual([{ text: 'Custom prompt content.' }]); - }); - - it('uses inline text when systemPrompt is not a file path', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - systemPrompt: 'Inline prompt text here', - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.systemPrompt).toEqual([{ text: 'Inline prompt text here' }]); - }); - - it('throws when prompt file exceeds max size', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - systemPrompt: './too-large.md', - } as any, - }); - await expect(mapHarnessSpecToCreateOptions(opts)).rejects.toThrow('too large'); - }); - }); - - describe('tools mapping', () => { - it('maps tools with type, name, and config', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [ - { type: 'remote_mcp', name: 'my-mcp', config: { remoteMcp: { url: 'https://example.com' } } }, - { type: 'agentcore_code_interpreter', name: 'code-interp' }, - ], - skills: [], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.tools).toEqual([ - { type: 'remote_mcp', name: 'my-mcp', config: { remoteMcp: { url: 'https://example.com' } } }, - { type: 'agentcore_code_interpreter', name: 'code-interp' }, - ]); - }); - - it('omits tools when empty array', async () => { - const result = await mapHarnessSpecToCreateOptions(baseOptions()); - expect(result.tools).toBeUndefined(); - }); - }); - - describe('skills mapping', () => { - it('maps skills as path objects', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: ['path/to/skill1', 'path/to/skill2'], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.skills).toEqual([{ path: 'path/to/skill1' }, { path: 'path/to/skill2' }]); - }); - }); - - describe('memory mapping', () => { - it('maps memory with direct ARN', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - memory: { arn: 'arn:aws:bedrock:us-east-1:111:memory/mem-123' }, - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.memory).toEqual({ - agentCoreMemoryConfiguration: { arn: 'arn:aws:bedrock:us-east-1:111:memory/mem-123' }, - }); - }); - - it('resolves memory by name from deployed state', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - memory: { name: 'my-memory' }, - } as any, - deployedResources: { - memories: { 'my-memory': { memoryArn: 'arn:aws:bedrock:us-east-1:111:memory/mem-resolved' } }, - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.memory).toEqual({ - agentCoreMemoryConfiguration: { arn: 'arn:aws:bedrock:us-east-1:111:memory/mem-resolved' }, - }); - }); - - it('throws when memory name cannot be resolved', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - memory: { name: 'missing-memory' }, - } as any, - }); - await expect(mapHarnessSpecToCreateOptions(opts)).rejects.toThrow('not in deployed state'); - }); - - it('includes retrievalConfig derived from memory strategy namespaces', async () => { - const deployedResources: DeployedResourceState = { - memories: { - my_memory: { - memoryId: 'mem-123', - memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', - }, - }, - }; - const memorySpec: Memory = { - name: 'my_memory', - eventExpiryDuration: 30, - strategies: [ - { type: 'SEMANTIC', namespaces: ['/users/{actorId}/facts'] }, - { type: 'USER_PREFERENCE', namespaces: ['/users/{actorId}/preferences'] }, - { type: 'SUMMARIZATION', namespaces: ['/summaries/{actorId}/{sessionId}'] }, - { - type: 'EPISODIC', - namespaces: ['/episodes/{actorId}/{sessionId}'], - reflectionNamespaces: ['/episodes/{actorId}'], - }, - ], - }; - - const result = await mapHarnessSpecToCreateOptions( - baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - memory: { name: 'my_memory' }, - } as any, - deployedResources, - memorySpec, - }) - ); - - expect(result.memory).toEqual({ - agentCoreMemoryConfiguration: { - arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', - retrievalConfig: { - '/users/{actorId}/facts': {}, - '/users/{actorId}/preferences': {}, - '/summaries/{actorId}/{sessionId}': {}, - '/episodes/{actorId}/{sessionId}': {}, - '/episodes/{actorId}': {}, - }, - }, - }); - }); - - it('includes EPISODIC reflectionNamespaces in retrievalConfig even without namespaces', async () => { - const deployedResources: DeployedResourceState = { - memories: { - my_memory: { - memoryId: 'mem-123', - memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', - }, - }, - }; - const memorySpec: Memory = { - name: 'my_memory', - eventExpiryDuration: 30, - strategies: [ - { type: 'SEMANTIC' }, - { - type: 'EPISODIC', - reflectionNamespaces: ['/episodes/{actorId}'], - }, - ], - }; - - const result = await mapHarnessSpecToCreateOptions( - baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - memory: { name: 'my_memory' }, - } as any, - deployedResources, - memorySpec, - }) - ); - - expect(result.memory?.agentCoreMemoryConfiguration.retrievalConfig).toEqual({ - '/episodes/{actorId}': {}, - }); - }); - - it('omits retrievalConfig when strategies have no namespaces or reflectionNamespaces', async () => { - const deployedResources: DeployedResourceState = { - memories: { - my_memory: { - memoryId: 'mem-123', - memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', - }, - }, - }; - const memorySpec: Memory = { - name: 'my_memory', - eventExpiryDuration: 30, - strategies: [{ type: 'SEMANTIC' }, { type: 'SUMMARIZATION' }], - }; - - const result = await mapHarnessSpecToCreateOptions( - baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - memory: { name: 'my_memory' }, - } as any, - deployedResources, - memorySpec, - }) - ); - - expect(result.memory?.agentCoreMemoryConfiguration.retrievalConfig).toBeUndefined(); - }); - - it('omits retrievalConfig when memorySpec not provided', async () => { - const deployedResources: DeployedResourceState = { - memories: { - my_memory: { - memoryId: 'mem-123', - memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', - }, - }, - }; - - const result = await mapHarnessSpecToCreateOptions( - baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - memory: { name: 'my_memory' }, - } as any, - deployedResources, - }) - ); - - expect(result.memory?.agentCoreMemoryConfiguration.retrievalConfig).toBeUndefined(); - }); - - it('includes both actorId and retrievalConfig when both are set', async () => { - const deployedResources: DeployedResourceState = { - memories: { - my_memory: { - memoryId: 'mem-123', - memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', - }, - }, - }; - const memorySpec: Memory = { - name: 'my_memory', - eventExpiryDuration: 30, - strategies: [{ type: 'SEMANTIC', namespaces: ['/users/{actorId}/facts'] }], - }; - - const result = await mapHarnessSpecToCreateOptions( - baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - memory: { name: 'my_memory', actorId: 'alice' }, - } as any, - deployedResources, - memorySpec, - }) - ); - - expect(result.memory).toEqual({ - agentCoreMemoryConfiguration: { - arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123', - actorId: 'alice', - retrievalConfig: { - '/users/{actorId}/facts': {}, - }, - }, - }); - }); - }); - - describe('execution limits', () => { - it('passes through maxIterations, maxTokens, timeoutSeconds', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - maxIterations: 10, - maxTokens: 4096, - timeoutSeconds: 120, - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.maxIterations).toBe(10); - expect(result.maxTokens).toBe(4096); - expect(result.timeoutSeconds).toBe(120); - }); - }); - - describe('container artifact', () => { - it('maps direct containerUri', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - containerUri: '111.dkr.ecr.us-east-1.amazonaws.com/repo:tag', - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.environmentArtifact).toEqual({ - containerConfiguration: { containerUri: '111.dkr.ecr.us-east-1.amazonaws.com/repo:tag' }, - }); - }); - - it('resolves container URI from CDK outputs for dockerfile', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'my-env', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - dockerfile: 'Dockerfile', - } as any, - cdkOutputs: { ApplicationHarnessMyEnvImageUriOutput123: '111.dkr.ecr.us-east-1.amazonaws.com/built:latest' }, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.environmentArtifact).toEqual({ - containerConfiguration: { containerUri: '111.dkr.ecr.us-east-1.amazonaws.com/built:latest' }, - }); - }); - - it('throws when dockerfile specified but no CDK output found', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - dockerfile: 'Dockerfile', - } as any, - cdkOutputs: {}, - }); - await expect(mapHarnessSpecToCreateOptions(opts)).rejects.toThrow('no container URI was found'); - }); - }); - - describe('environment provider', () => { - it('maps network config', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - networkConfig: { subnets: ['subnet-1'], securityGroups: ['sg-1'] }, - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.environment).toEqual({ - agentCoreRuntimeEnvironment: { - networkConfiguration: { - networkMode: 'VPC', - networkModeConfig: { subnets: ['subnet-1'], securityGroups: ['sg-1'] }, - }, - }, - }); - }); - - it('maps sessionStoragePath', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - sessionStoragePath: '/mnt/storage', - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.environment).toEqual({ - agentCoreRuntimeEnvironment: { - filesystemConfigurations: [{ sessionStorage: { mountPath: '/mnt/storage' } }], - }, - }); - }); - - it('maps efsAccessPoints to filesystemConfigurations', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - networkMode: 'VPC', - networkConfig: { subnets: ['subnet-abc'], securityGroups: ['sg-abc'] }, - efsAccessPoints: [ - { - accessPointArn: 'arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-0123456789abcdef0', - mountPath: '/mnt/efs', - }, - ], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.environment?.agentCoreRuntimeEnvironment?.filesystemConfigurations).toContainEqual({ - efsAccessPoint: { - accessPointArn: 'arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-0123456789abcdef0', - mountPath: '/mnt/efs', - }, - }); - }); - - it('maps s3AccessPoints to filesystemConfigurations', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - networkMode: 'VPC', - networkConfig: { subnets: ['subnet-abc'], securityGroups: ['sg-abc'] }, - s3AccessPoints: [ - { - accessPointArn: - 'arn:aws:s3files:us-east-1:123456789012:file-system/fs-12345678901234567/access-point/fsap-12345678901234567', - mountPath: '/mnt/s3', - }, - ], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.environment?.agentCoreRuntimeEnvironment?.filesystemConfigurations).toContainEqual({ - s3FilesAccessPoint: { - accessPointArn: - 'arn:aws:s3files:us-east-1:123456789012:file-system/fs-12345678901234567/access-point/fsap-12345678901234567', - mountPath: '/mnt/s3', - }, - }); - }); - - it('maps all three filesystem types together', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - networkMode: 'VPC', - networkConfig: { subnets: ['subnet-abc'], securityGroups: ['sg-abc'] }, - sessionStoragePath: '/mnt/session', - efsAccessPoints: [ - { - accessPointArn: 'arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-0123456789abcdef0', - mountPath: '/mnt/efs', - }, - ], - s3AccessPoints: [ - { - accessPointArn: - 'arn:aws:s3files:us-east-1:123456789012:file-system/fs-12345678901234567/access-point/fsap-12345678901234567', - mountPath: '/mnt/s3', - }, - ], - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - const fcs = result.environment?.agentCoreRuntimeEnvironment?.filesystemConfigurations as unknown[]; - expect(fcs).toHaveLength(3); - expect(fcs[0]).toEqual({ sessionStorage: { mountPath: '/mnt/session' } }); - expect(fcs[1]).toMatchObject({ efsAccessPoint: { mountPath: '/mnt/efs' } }); - expect(fcs[2]).toMatchObject({ s3FilesAccessPoint: { mountPath: '/mnt/s3' } }); - }); - - it('returns no environment when no network/lifecycle/storage', async () => { - const result = await mapHarnessSpecToCreateOptions(baseOptions()); - expect(result.environment).toBeUndefined(); - }); - }); - - describe('authorizer configuration', () => { - it('maps custom JWT authorizer', async () => { - const opts = baseOptions({ - harnessSpec: { - name: 'h', - model: { provider: 'bedrock', modelId: 'claude' }, - tools: [], - skills: [], - authorizerConfiguration: { - customJwtAuthorizer: { - discoveryUrl: 'https://example.com/.well-known/openid-configuration', - allowedAudience: ['aud1'], - allowedClients: ['client1'], - }, - }, - } as any, - }); - const result = await mapHarnessSpecToCreateOptions(opts); - expect(result.authorizerConfiguration).toEqual({ - customJWTAuthorizer: { - discoveryUrl: 'https://example.com/.well-known/openid-configuration', - allowedAudience: ['aud1'], - allowedClients: ['client1'], - }, - }); - }); - }); -}); diff --git a/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts b/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts deleted file mode 100644 index 8776b15e7..000000000 --- a/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * HarnessDeployer - Post-CDK imperative deployer for Harness resources. - * - * Runs after CDK deploy to create, update, or delete harness resources - * via the SigV4 API client. Harness role ARNs are resolved from CDK - * stack outputs, and harness specs are read from disk (harness.json). - */ -import type { HarnessDeployedState, HarnessMemoryRef, HarnessSpec, Memory } from '../../../../../schema'; -import { HarnessSpecSchema } from '../../../../../schema'; -import type { DeployedResourceState } from '../../../../../schema/schemas/deployed-state'; -import type { - CreateHarnessResult, - Harness, - UpdateHarnessOptions, - UpdateHarnessResult, -} from '../../../../aws/agentcore-harness'; -import { createHarness, deleteHarness, getHarness, updateHarness } from '../../../../aws/agentcore-harness'; -import { AgentCoreApiError } from '../../../../aws/api-client'; -import { toPascalId } from '../../../../cloudformation/logical-ids'; -import type { DeployPhase, ImperativeDeployContext, ImperativeDeployResult, ImperativeDeployer } from '../types'; -import { mapHarnessSpecToCreateOptions } from './harness-mapper'; -import { readFile } from 'fs/promises'; -import { createHash } from 'node:crypto'; -import { dirname, join } from 'path'; - -const ROLE_VALIDATION_RETRY_DELAYS_MS = [5_000, 10_000, 15_000, 20_000, 30_000]; -const READY_POLL_INTERVAL_MS = 3_000; -const READY_POLL_MAX_ATTEMPTS = 40; // 2 minutes max - -// ============================================================================ -// Types -// ============================================================================ - -type HarnessDeployedStateMap = Record; - -async function computeHarnessHash( - harnessDir: string, - harnessSpec: HarnessSpec, - roleArn: string, - memorySpec?: Memory -): Promise { - const hash = createHash('sha256'); - hash.update(JSON.stringify(harnessSpec)); - hash.update(roleArn); - if (memorySpec) { - hash.update(JSON.stringify(memorySpec)); - } - try { - const promptContent = await readFile(join(harnessDir, 'system-prompt.md'), 'utf-8'); - hash.update(promptContent); - } catch { - // no system-prompt.md - } - if (harnessSpec.dockerfile) { - try { - const dockerfileContent = await readFile(join(harnessDir, harnessSpec.dockerfile), 'utf-8'); - hash.update(dockerfileContent); - } catch { - // Dockerfile missing — preflight already validates existence before deploy - } - } - return hash.digest('hex').slice(0, 16); -} - -function resolveMemorySpec( - memories: Memory[] | undefined, - memoryRef: HarnessMemoryRef | undefined, - deployedResources: DeployedResourceState | undefined -): Memory | undefined { - if (!memoryRef) return undefined; - if (memoryRef.name) return memories?.find(m => m.name === memoryRef.name); - if (memoryRef.arn && deployedResources?.memories) { - const entry = Object.entries(deployedResources.memories).find(([, v]) => v.memoryArn === memoryRef.arn); - if (entry) return memories?.find(m => m.name === entry[0]); - } - return undefined; -} - -// ============================================================================ -// Deployer -// ============================================================================ - -export class HarnessDeployer implements ImperativeDeployer { - readonly name = 'harness'; - readonly label = 'Harnesses'; - readonly phase: DeployPhase = 'post-cdk'; - - shouldRun(context: ImperativeDeployContext): boolean { - const projectHarnesses = context.projectSpec.harnesses; - const hasProjectHarnesses = !!projectHarnesses && projectHarnesses.length > 0; - - const targetName = context.target.name; - const deployedHarnesses = context.deployedState.targets?.[targetName]?.resources?.harnesses; - const hasDeployedHarnesses = !!deployedHarnesses && Object.keys(deployedHarnesses).length > 0; - - return hasProjectHarnesses || hasDeployedHarnesses; - } - - async deploy(context: ImperativeDeployContext): Promise> { - const { projectSpec, target, configIO, deployedState, cdkOutputs } = context; - const region = target.region; - const targetName = target.name; - const projectName = projectSpec.name; - const configRoot = configIO.getConfigRoot(); - const projectRoot = dirname(configRoot); - - const projectHarnesses = projectSpec.harnesses ?? []; - const deployedHarnesses = deployedState.targets?.[targetName]?.resources?.harnesses ?? {}; - const resultState: HarnessDeployedStateMap = { ...deployedHarnesses }; - const notes: string[] = []; - - // Build set of harness names in current project spec - const projectHarnessNames = new Set(projectHarnesses.map(h => h.name)); - - // Create or update each harness in the project spec - for (const entry of projectHarnesses) { - // Harness path is relative to project root (like agent codeLocation) - const harnessDir = join(projectRoot, entry.path); - - // Read harness.json from disk and validate - let harnessSpec: HarnessSpec; - try { - const raw = await readFile(join(harnessDir, 'harness.json'), 'utf-8'); - const parsed: unknown = JSON.parse(raw); - const validated = HarnessSpecSchema.safeParse(parsed); - if (!validated.success) { - return { - success: false, - error: `Invalid harness.json for "${entry.name}": ${validated.error.message}`, - state: resultState, - }; - } - harnessSpec = validated.data; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - success: false, - error: `Failed to read harness.json for "${entry.name}": ${message}`, - state: resultState, - }; - } - - // Resolve role ARN from CDK outputs - const roleArn = resolveRoleArn(entry.name, cdkOutputs); - if (!roleArn) { - return { - success: false, - error: `Could not find role ARN in CDK outputs for harness "${entry.name}". Expected output key starting with "ApplicationHarness${toPascalId(entry.name)}RoleArn" or "ApplicationHarness${toPascalId(entry.name)}RoleRoleArn".`, - state: resultState, - }; - } - - // Use executionRoleArn from harness spec if provided, otherwise use CDK output - const executionRoleArn = harnessSpec.executionRoleArn ?? roleArn; - - const deployedResources = deployedState.targets?.[targetName]?.resources; - const existingHarness = deployedHarnesses[entry.name]; - const memorySpec = resolveMemorySpec(projectSpec.memories, harnessSpec.memory, deployedResources); - - const configHash = await computeHarnessHash(harnessDir, harnessSpec, executionRoleArn, memorySpec); - - if (existingHarness?.configHash === configHash) { - resultState[entry.name] = existingHarness; - notes.push(`Harness "${entry.name}" unchanged, skipped`); - context.onProgress?.(`Harness "${entry.name}": no changes`, 'done'); - continue; - } - - try { - if (existingHarness) { - // Update existing harness - const createOptions = await mapHarnessSpecToCreateOptions({ - harnessSpec, - harnessDir, - executionRoleArn, - region, - projectName, - deployedResources, - cdkOutputs, - memorySpec, - }); - - // Memory uses { optionalValue: null } to explicitly clear it when removed from config, - // since the API treats an absent field as "no change" but null as "remove". - // environmentArtifact uses undefined (omit) because container config is immutable - // after creation — it cannot be cleared via update, only set on create. - const updateOptions: UpdateHarnessOptions = { - region, - harnessId: existingHarness.harnessId, - executionRoleArn: createOptions.executionRoleArn, - model: createOptions.model, - systemPrompt: createOptions.systemPrompt, - tools: createOptions.tools, - skills: createOptions.skills, - allowedTools: createOptions.allowedTools, - memory: createOptions.memory ? { optionalValue: createOptions.memory } : { optionalValue: null }, - truncation: createOptions.truncation, - maxIterations: createOptions.maxIterations, - maxTokens: createOptions.maxTokens, - timeoutSeconds: createOptions.timeoutSeconds, - environment: createOptions.environment, - environmentArtifact: createOptions.environmentArtifact - ? { optionalValue: createOptions.environmentArtifact } - : undefined, - environmentVariables: createOptions.environmentVariables, - tags: createOptions.tags, - authorizerConfiguration: createOptions.authorizerConfiguration - ? { optionalValue: createOptions.authorizerConfiguration } - : { optionalValue: null }, - }; - - const updateResult: UpdateHarnessResult = await updateHarness(updateOptions); - const finalHarness = await waitForReady(region, updateResult.harness); - if (finalHarness.status === 'FAILED') { - throw new Error(`Harness "${entry.name}" entered FAILED state`); - } - resultState[entry.name] = { - harnessId: finalHarness.harnessId, - harnessArn: finalHarness.arn, - roleArn: executionRoleArn, - status: finalHarness.status, - agentRuntimeArn: extractRuntimeArn(finalHarness), - memoryArn: createOptions.memory?.agentCoreMemoryConfiguration?.arn, - configHash, - }; - notes.push(`Updated harness "${entry.name}"`); - } else { - // Create new harness (with retry for IAM role propagation delay) - const createOptions = await mapHarnessSpecToCreateOptions({ - harnessSpec, - harnessDir, - executionRoleArn, - region, - projectName, - deployedResources, - cdkOutputs, - memorySpec, - }); - - const createResult: CreateHarnessResult = await createWithRetry(createOptions); - const finalHarness = await waitForReady(region, createResult.harness); - if (finalHarness.status === 'FAILED') { - throw new Error(`Harness "${entry.name}" entered FAILED state`); - } - resultState[entry.name] = { - harnessId: finalHarness.harnessId, - harnessArn: finalHarness.arn, - roleArn: executionRoleArn, - status: finalHarness.status, - agentRuntimeArn: extractRuntimeArn(finalHarness), - memoryArn: createOptions.memory?.agentCoreMemoryConfiguration?.arn, - configHash, - }; - notes.push(`Created harness "${entry.name}"`); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const hint = getDeployErrorHint(err, region); - const errorMsg = hint - ? `Failed to deploy harness "${entry.name}": ${message}\n${hint}` - : `Failed to deploy harness "${entry.name}": ${message}`; - return { success: false, error: errorMsg, state: resultState }; - } - } - - // Delete harnesses that exist in deployed state but not in project spec - for (const [name, state] of Object.entries(deployedHarnesses)) { - if (!projectHarnessNames.has(name)) { - try { - await deleteHarness({ region, harnessId: state.harnessId }); - delete resultState[name]; - notes.push(`Deleted harness "${name}"`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: `Failed to delete harness "${name}": ${message}`, state: resultState }; - } - } - } - - return { success: true, state: resultState, notes }; - } - - async teardown(context: ImperativeDeployContext): Promise> { - const { target, deployedState } = context; - const region = target.region; - const targetName = target.name; - - const deployedHarnesses = deployedState.targets?.[targetName]?.resources?.harnesses ?? {}; - const notes: string[] = []; - - for (const [name, state] of Object.entries(deployedHarnesses)) { - try { - await deleteHarness({ region, harnessId: state.harnessId }); - notes.push(`Deleted harness "${name}"`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { success: false, error: `Failed to delete harness "${name}": ${message}` }; - } - } - - return { success: true, state: {}, notes }; - } -} - -// ============================================================================ -// Helpers -// ============================================================================ - -/** - * Resolve the IAM role ARN for a harness from CDK stack outputs. - * - * Supports two construct tree layouts: - * Old (AgentCoreHarnessRole directly under Application): - * ApplicationHarness{PascalName}RoleArnOutput... - * New (AgentCoreHarnessEnvironment wrapping AgentCoreHarnessRole): - * ApplicationHarness{PascalName}RoleRoleArnOutput... - */ -function resolveRoleArn(harnessName: string, cdkOutputs?: Record): string | undefined { - if (!cdkOutputs) return undefined; - - const pascalName = toPascalId(harnessName); - // Longer prefix first — RoleArn is a substring of RoleRoleArn, so checking it first would match both. - const prefixes = [`ApplicationHarness${pascalName}RoleRoleArn`, `ApplicationHarness${pascalName}RoleArn`]; - - for (const [key, value] of Object.entries(cdkOutputs)) { - if (prefixes.some(p => key.startsWith(p))) { - return value; - } - } - - return undefined; -} - -function isRoleValidationError(err: unknown): boolean { - return err instanceof AgentCoreApiError && err.statusCode === 400 && err.errorBody.includes('Role validation failed'); -} - -async function createWithRetry(options: Parameters[0]): Promise { - let lastError: unknown; - for (let attempt = 0; attempt <= ROLE_VALIDATION_RETRY_DELAYS_MS.length; attempt++) { - try { - return await createHarness(options); - } catch (err) { - if (!isRoleValidationError(err) || attempt === ROLE_VALIDATION_RETRY_DELAYS_MS.length) { - throw err; - } - lastError = err; - await sleep(ROLE_VALIDATION_RETRY_DELAYS_MS[attempt]!); - } - } - throw lastError; -} - -async function waitForReady(region: string, harness: Harness): Promise { - if (harness.status === 'READY' || harness.status === 'FAILED') return harness; - - for (let i = 0; i < READY_POLL_MAX_ATTEMPTS; i++) { - await sleep(READY_POLL_INTERVAL_MS); - const result = await getHarness({ region, harnessId: harness.harnessId }); - if (result.harness.status === 'READY' || result.harness.status === 'FAILED') return result.harness; - } - - return harness; -} - -function extractRuntimeArn(harness: Harness): string | undefined { - return harness.environment?.agentCoreRuntimeEnvironment?.agentRuntimeArn; -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function getDeployErrorHint(err: unknown, region: string): string | undefined { - if (!(err instanceof AgentCoreApiError)) return undefined; - const body = err.errorBody.toLowerCase(); - - if (err.statusCode === 403) { - return 'Check that your AWS credentials have permission to call the AgentCore Harness API.'; - } - if (body.includes('not available') || body.includes('not supported') || body.includes('endpoint')) { - return `Harness may not be available in ${region}. Try a different region (e.g., us-east-1, us-west-2).`; - } - if (err.statusCode === 429) { - return 'Too many requests. Wait a moment and try again.'; - } - if (err.statusCode >= 500) { - return 'This looks like a service-side issue. Wait a moment and redeploy.'; - } - return undefined; -} diff --git a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts deleted file mode 100644 index 165cc5d19..000000000 --- a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts +++ /dev/null @@ -1,433 +0,0 @@ -/** - * Maps user-facing HarnessSpec (harness.json) to the CreateHarness API wire format. - * - * Each transformation is a pure function that converts a section of the spec - * into the corresponding API field. The top-level mapHarnessSpecToCreateOptions - * orchestrates them and returns a complete CreateHarnessOptions object. - */ -import type { DeployedResourceState, HarnessSpec, Memory } from '../../../../../schema'; -import type { - CreateHarnessOptions, - HarnessEnvironmentArtifact, - HarnessEnvironmentProvider, - HarnessMemoryConfiguration, - HarnessModelConfiguration, - HarnessSkill, - HarnessSystemPrompt, - HarnessTool, - HarnessTruncationConfiguration, -} from '../../../../aws/agentcore-harness'; -import { toPascalId } from '../../../../cloudformation/logical-ids'; -import { buildFilesystemConfigurations } from '../../../../commands/shared/filesystem-utils'; -import { readFile, stat } from 'fs/promises'; -import { join } from 'path'; - -const MAX_PROMPT_FILE_SIZE = 1024 * 1024; // 1 MB - -// ============================================================================ -// Public Interface -// ============================================================================ - -export interface MapHarnessOptions { - harnessSpec: HarnessSpec; - harnessDir: string; - executionRoleArn: string; - region: string; - projectName: string; - deployedResources?: DeployedResourceState; - cdkOutputs?: Record; - /** The memory spec for the memory this harness references, used to derive retrievalConfig namespaces. */ - memorySpec?: Memory; -} - -/** - * Transform a HarnessSpec into CreateHarnessOptions for the control plane API. - */ -export async function mapHarnessSpecToCreateOptions(options: MapHarnessOptions): Promise { - const { harnessSpec, harnessDir, executionRoleArn, region, projectName, deployedResources, cdkOutputs, memorySpec } = - options; - - const result: CreateHarnessOptions = { - region, - harnessName: `${projectName}_${harnessSpec.name}`, - executionRoleArn, - }; - - // Model - result.model = mapModel(harnessSpec.model); - - // System prompt (may read from disk or auto-discover system-prompt.md) - if (harnessSpec.systemPrompt !== undefined) { - result.systemPrompt = await mapSystemPrompt(harnessSpec.systemPrompt, harnessDir); - } else { - // Auto-discover system-prompt.md if it exists - result.systemPrompt = await tryLoadSystemPromptFile(harnessDir); - } - - // Tools - if (harnessSpec.tools.length > 0) { - result.tools = mapTools(harnessSpec.tools); - } - - // Skills - if (harnessSpec.skills.length > 0) { - result.skills = mapSkills(harnessSpec.skills); - } - - // Allowed tools - if (harnessSpec.allowedTools) { - result.allowedTools = harnessSpec.allowedTools; - } - - // Memory - if (harnessSpec.memory) { - result.memory = mapMemory(harnessSpec.memory, deployedResources, cdkOutputs, memorySpec); - } - - // Truncation - if (harnessSpec.truncation) { - result.truncation = mapTruncation(harnessSpec.truncation); - } - - // Execution limits - if (harnessSpec.maxIterations !== undefined) { - result.maxIterations = harnessSpec.maxIterations; - } - if (harnessSpec.maxTokens !== undefined) { - result.maxTokens = harnessSpec.maxTokens; - } - if (harnessSpec.timeoutSeconds !== undefined) { - result.timeoutSeconds = harnessSpec.timeoutSeconds; - } - - // Container artifact - if (harnessSpec.containerUri) { - result.environmentArtifact = mapEnvironmentArtifact(harnessSpec.containerUri); - } else if (harnessSpec.dockerfile) { - const builtUri = resolveContainerUriFromOutputs(harnessSpec.name, cdkOutputs); - if (!builtUri) { - throw new Error( - `Harness "${harnessSpec.name}" specifies "dockerfile" but no container URI was found in CDK outputs. ` + - `Expected a CDK output key starting with "ApplicationHarness${toPascalId(harnessSpec.name)}ImageUri" or "Harness${toPascalId(harnessSpec.name)}ContainerUri".` - ); - } - result.environmentArtifact = mapEnvironmentArtifact(builtUri); - } - - // Environment provider (network + lifecycle) - const environmentProvider = mapEnvironmentProvider(harnessSpec); - if (environmentProvider) { - result.environment = environmentProvider; - } - - // Environment variables - if (harnessSpec.environmentVariables) { - result.environmentVariables = harnessSpec.environmentVariables; - } - - // Tags - if (harnessSpec.tags) { - result.tags = harnessSpec.tags; - } - - // Authorizer configuration — authorizerType is inferred by the API from the - // presence of authorizerConfiguration, so only the configuration is forwarded. - if (harnessSpec.authorizerConfiguration?.customJwtAuthorizer) { - const jwt = harnessSpec.authorizerConfiguration.customJwtAuthorizer; - result.authorizerConfiguration = { - customJWTAuthorizer: { - discoveryUrl: jwt.discoveryUrl, - ...(jwt.allowedAudience && { allowedAudience: jwt.allowedAudience }), - ...(jwt.allowedClients && { allowedClients: jwt.allowedClients }), - ...(jwt.allowedScopes && { allowedScopes: jwt.allowedScopes }), - ...(jwt.customClaims && { customClaims: jwt.customClaims }), - }, - }; - } - - return result; -} - -// ============================================================================ -// Model Mapping -// ============================================================================ - -function mapModel(model: HarnessSpec['model']): HarnessModelConfiguration { - const { provider, modelId, apiKeyArn, apiFormat, temperature, topP, topK, maxTokens } = model; - - switch (provider) { - case 'bedrock': - return { - bedrockModelConfig: { - modelId, - ...(apiFormat && apiFormat !== 'converse_stream' && { apiFormat }), - ...(temperature !== undefined && { temperature }), - ...(topP !== undefined && { topP }), - ...(maxTokens !== undefined && { maxTokens }), - }, - }; - case 'open_ai': - return { - openAiModelConfig: { - modelId, - ...(apiKeyArn && { apiKeyArn }), - ...(apiFormat && apiFormat !== 'responses' && { apiFormat: apiFormat as 'responses' | 'chat_completions' }), - ...(temperature !== undefined && { temperature }), - ...(topP !== undefined && { topP }), - ...(maxTokens !== undefined && { maxTokens }), - }, - }; - case 'gemini': - return { - geminiModelConfig: { - modelId, - ...(apiKeyArn && { apiKeyArn }), - ...(temperature !== undefined && { temperature }), - ...(topP !== undefined && { topP }), - ...(topK !== undefined && { topK }), - ...(maxTokens !== undefined && { maxTokens }), - }, - }; - } -} - -// ============================================================================ -// System Prompt Mapping -// ============================================================================ - -const FILE_PATH_PATTERN = /^\.\.?\//; -const FILE_EXTENSION_PATTERN = /\.(md|txt)$/; - -function isFilePath(value: string): boolean { - return FILE_PATH_PATTERN.test(value) || FILE_EXTENSION_PATTERN.test(value); -} - -async function mapSystemPrompt(prompt: string, harnessDir: string): Promise { - let text: string; - - if (isFilePath(prompt)) { - const filePath = join(harnessDir, prompt); - const fileStats = await stat(filePath); - if (fileStats.size > MAX_PROMPT_FILE_SIZE) { - throw new Error( - `System prompt file "${prompt}" is too large (${fileStats.size} bytes). Maximum size is ${MAX_PROMPT_FILE_SIZE} bytes.` - ); - } - text = await readFile(filePath, 'utf-8'); - } else { - text = prompt; - } - - return [{ text }]; -} - -/** - * Try to load system-prompt.md from harness directory. - * Returns undefined if file doesn't exist (harness will have no system prompt). - */ -async function tryLoadSystemPromptFile(harnessDir: string): Promise { - const promptPath = join(harnessDir, 'system-prompt.md'); - - try { - const fileStats = await stat(promptPath); - if (fileStats.size > MAX_PROMPT_FILE_SIZE) { - throw new Error( - `System prompt file "system-prompt.md" is too large (${fileStats.size} bytes). Maximum size is ${MAX_PROMPT_FILE_SIZE} bytes.` - ); - } - const text = await readFile(promptPath, 'utf-8'); - return [{ text }]; - } catch (err) { - // File doesn't exist - return undefined (no system prompt) - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return undefined; - } - // Other errors (permissions, etc.) should be thrown - throw err; - } -} - -// ============================================================================ -// Tools Mapping -// ============================================================================ - -function mapTools(tools: HarnessSpec['tools']): HarnessTool[] { - return tools.map(tool => ({ - type: tool.type, - name: tool.name, - ...(tool.config && { config: tool.config }), - })); -} - -// ============================================================================ -// Skills Mapping -// ============================================================================ - -function mapSkills(skills: string[]): HarnessSkill[] { - return skills.map(path => ({ path })); -} - -// ============================================================================ -// Memory Mapping -// ============================================================================ - -function mapMemory( - memory: NonNullable, - deployedResources?: DeployedResourceState, - cdkOutputs?: Record, - memorySpec?: Memory -): HarnessMemoryConfiguration | undefined { - let arn: string | undefined; - - // Direct ARN takes precedence - if (memory.arn) { - arn = memory.arn; - } else if (memory.name) { - // Resolve by name from deployed state or CDK outputs - const deployedMemory = deployedResources?.memories?.[memory.name]; - if (deployedMemory) { - arn = deployedMemory.memoryArn; - } else if (cdkOutputs) { - arn = resolveMemoryArnFromOutputs(memory.name, cdkOutputs); - } - - if (!arn) { - throw new Error( - `Memory "${memory.name}" referenced by harness is not in deployed state. Ensure the memory is defined in agentcore.json and has been deployed.` - ); - } - } - - if (!arn) { - return undefined; - } - - // Build retrievalConfig from the memory's strategy namespaces so the harness - // runtime knows which namespaces to search at inference time. - const retrievalConfig = buildRetrievalConfig(memorySpec); - - return { - agentCoreMemoryConfiguration: { - arn, - ...(memory.actorId && { actorId: memory.actorId }), - ...(retrievalConfig && { retrievalConfig }), - }, - }; -} - -function buildRetrievalConfig( - memorySpec: Memory | undefined -): Record | undefined { - if (!memorySpec?.strategies?.length) return undefined; - - const namespaces = memorySpec.strategies.flatMap(s => [ - ...(s.namespaces ?? []), - ...(s.type === 'EPISODIC' ? (s.reflectionNamespaces ?? []) : []), - ]); - - return namespaces.length > 0 ? Object.fromEntries(namespaces.map(ns => [ns, {}])) : undefined; -} - -/** - * Resolve memory ARN from CDK stack outputs. - * The CDK construct exports memory ARNs with keys matching: - * ApplicationMemory{PascalName}ArnOutput... - */ -function resolveMemoryArnFromOutputs(memoryName: string, cdkOutputs: Record): string | undefined { - const pascalName = toPascalId(memoryName); - const prefix = `ApplicationMemory${pascalName}ArnOutput`; - - for (const [key, value] of Object.entries(cdkOutputs)) { - if (key.startsWith(prefix)) { - return value; - } - } - - return undefined; -} - -// ============================================================================ -// Truncation Mapping -// ============================================================================ - -function mapTruncation(truncation: NonNullable): HarnessTruncationConfiguration { - return { - strategy: truncation.strategy, - config: truncation.config as HarnessTruncationConfiguration['config'], - }; -} - -// ============================================================================ -// Container URI Resolution (from CDK outputs for dockerfile-based harnesses) -// ============================================================================ - -/** - * Supports two construct tree layouts: - * Old (CfnOutput on stack root): - * Harness{PascalName}ContainerUri... - * New (CfnOutput inside AgentCoreHarnessEnvironment): - * ApplicationHarness{PascalName}ImageUriOutput... - */ -function resolveContainerUriFromOutputs(harnessName: string, cdkOutputs?: Record): string | undefined { - if (!cdkOutputs) return undefined; - - const pascalName = toPascalId(harnessName); - const prefixes = [`ApplicationHarness${pascalName}ImageUri`, `Harness${pascalName}ContainerUri`]; - - for (const [key, value] of Object.entries(cdkOutputs)) { - if (prefixes.some(p => key.startsWith(p))) { - return value; - } - } - - return undefined; -} - -// ============================================================================ -// Container / Environment Artifact Mapping -// ============================================================================ - -function mapEnvironmentArtifact(containerUri: string): HarnessEnvironmentArtifact { - return { - containerConfiguration: { containerUri }, - }; -} - -// ============================================================================ -// Environment Provider (Network + Lifecycle) Mapping -// ============================================================================ - -function mapEnvironmentProvider(spec: HarnessSpec): HarnessEnvironmentProvider | undefined { - const hasNetwork = !!spec.networkConfig; - const hasLifecycle = !!spec.lifecycleConfig; - const hasFilesystem = !!spec.sessionStoragePath || !!spec.efsAccessPoints?.length || !!spec.s3AccessPoints?.length; - - if (!hasNetwork && !hasLifecycle && !hasFilesystem) { - return undefined; - } - - const agentCoreRuntimeEnvironment: Record = {}; - - if (spec.networkConfig) { - agentCoreRuntimeEnvironment.networkConfiguration = { - networkMode: 'VPC', - networkModeConfig: { - subnets: spec.networkConfig.subnets, - securityGroups: spec.networkConfig.securityGroups, - }, - }; - } - - if (spec.lifecycleConfig) { - agentCoreRuntimeEnvironment.lifecycleConfiguration = spec.lifecycleConfig; - } - - const fsConfig = buildFilesystemConfigurations(spec.sessionStoragePath, spec.efsAccessPoints, spec.s3AccessPoints); - if ('filesystemConfigurations' in fsConfig) { - agentCoreRuntimeEnvironment.filesystemConfigurations = fsConfig.filesystemConfigurations; - } - - return { - agentCoreRuntimeEnvironment, - }; -} diff --git a/src/cli/operations/deploy/imperative/deployers/index.ts b/src/cli/operations/deploy/imperative/deployers/index.ts deleted file mode 100644 index 655785b10..000000000 --- a/src/cli/operations/deploy/imperative/deployers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { HarnessDeployer } from './harness-deployer'; -export { mapHarnessSpecToCreateOptions, type MapHarnessOptions } from './harness-mapper'; diff --git a/src/cli/operations/deploy/imperative/index.ts b/src/cli/operations/deploy/imperative/index.ts deleted file mode 100644 index 930dfe094..000000000 --- a/src/cli/operations/deploy/imperative/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HarnessDeployer } from './deployers'; -import { ImperativeDeploymentManager } from './manager'; - -export type { - DeployPhase, - DeployProgress, - ImperativeDeployContext, - ImperativeDeployResult, - ImperativeDeployer, -} from './types'; - -export { ImperativeDeploymentManager, type ImperativePhaseResult } from './manager'; - -export { HarnessDeployer, mapHarnessSpecToCreateOptions, type MapHarnessOptions } from './deployers'; - -export function createDeploymentManager(): ImperativeDeploymentManager { - return new ImperativeDeploymentManager().register(new HarnessDeployer()); -} diff --git a/src/cli/operations/deploy/imperative/manager.ts b/src/cli/operations/deploy/imperative/manager.ts deleted file mode 100644 index b7e22ecda..000000000 --- a/src/cli/operations/deploy/imperative/manager.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { DeployPhase, ImperativeDeployContext, ImperativeDeployResult, ImperativeDeployer } from './types'; - -export interface ImperativePhaseResult { - success: boolean; - results: Map; - error?: string; - notes: string[]; -} - -export class ImperativeDeploymentManager { - private readonly deployers: ImperativeDeployer[] = []; - - register(deployer: ImperativeDeployer): this { - this.deployers.push(deployer); - return this; - } - - async runPhase(phase: DeployPhase, context: ImperativeDeployContext): Promise { - const results = new Map(); - const notes: string[] = []; - - const applicable = this.deployers.filter(d => d.phase === phase && d.shouldRun(context)); - - for (const deployer of applicable) { - context.onProgress?.(deployer.label, 'start'); - - try { - const result = await deployer.deploy(context); - results.set(deployer.name, result); - - if (result.notes) { - notes.push(...result.notes); - } - - if (!result.success) { - context.onProgress?.(deployer.label, 'error'); - return { - success: false, - results, - error: result.error ?? `Deployer '${deployer.name}' failed`, - notes, - }; - } - - context.onProgress?.(deployer.label, 'done'); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - results.set(deployer.name, { success: false, error: errorMessage }); - context.onProgress?.(deployer.label, 'error'); - return { - success: false, - results, - error: errorMessage, - notes, - }; - } - } - - return { success: true, results, notes }; - } - - async teardownAll(context: ImperativeDeployContext): Promise { - const results = new Map(); - const notes: string[] = []; - const errors: string[] = []; - - const applicable = this.deployers.filter(d => d.shouldRun(context)).reverse(); - - for (const deployer of applicable) { - context.onProgress?.(deployer.label, 'start'); - - try { - const result = await deployer.teardown(context); - results.set(deployer.name, result); - - if (result.notes) { - notes.push(...result.notes); - } - - if (!result.success) { - context.onProgress?.(deployer.label, 'error'); - errors.push(result.error ?? `Teardown of '${deployer.name}' failed`); - continue; - } - - context.onProgress?.(deployer.label, 'done'); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - results.set(deployer.name, { success: false, error: errorMessage }); - context.onProgress?.(deployer.label, 'error'); - errors.push(errorMessage); - } - } - - if (errors.length > 0) { - return { - success: false, - results, - error: errors.join('; '), - notes, - }; - } - - return { success: true, results, notes }; - } - - hasDeployersForPhase(phase: DeployPhase, context: ImperativeDeployContext): boolean { - return this.deployers.some(d => d.phase === phase && d.shouldRun(context)); - } -} diff --git a/src/cli/operations/deploy/imperative/types.ts b/src/cli/operations/deploy/imperative/types.ts deleted file mode 100644 index 7efa13e7a..000000000 --- a/src/cli/operations/deploy/imperative/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { ConfigIO } from '../../../../lib'; -import type { AgentCoreProjectSpec, AwsDeploymentTarget, DeployedState } from '../../../../schema'; - -export type DeployPhase = 'pre-cdk' | 'post-cdk' | 'standalone'; - -export type DeployProgress = (step: string, status: 'start' | 'done' | 'error') => void; - -export interface ImperativeDeployContext { - projectSpec: AgentCoreProjectSpec; - target: AwsDeploymentTarget; - configIO: ConfigIO; - deployedState: DeployedState; - onProgress?: DeployProgress; - cdkOutputs?: Record; - autoConfirm?: boolean; -} - -export interface ImperativeDeployResult> { - success: boolean; - state?: TState; - notes?: string[]; - error?: string; -} - -export interface ImperativeDeployer> { - readonly name: string; - readonly label: string; - readonly phase: DeployPhase; - shouldRun(context: ImperativeDeployContext): boolean; - deploy(context: ImperativeDeployContext): Promise>; - teardown(context: ImperativeDeployContext): Promise>; -} diff --git a/src/cli/operations/deploy/index.ts b/src/cli/operations/deploy/index.ts index ed1c6fc1d..29f0fd7e7 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -55,14 +55,6 @@ export { // Post-deploy observability setup export { setupTransactionSearch } from './post-deploy-observability'; -// Post-deploy HTTP gateways -export { - setupHttpGateways, - type SetupHttpGatewaysOptions, - type SetupHttpGatewaysResult, - type HttpGatewaySetupResult, -} from './post-deploy-http-gateways'; - // Post-deploy online eval enablement export { enableOnlineEvalConfigs, @@ -73,13 +65,12 @@ export { export { ensureDefaultDeploymentTarget } from './ensure-target'; -// Post-deploy config bundles +// Managed-memory heads-up (shared by the CLI command + TUI deploy flow + add harness) export { - setupConfigBundles, - type SetupConfigBundlesOptions, - type SetupConfigBundlesResult, - type ConfigBundleSetupResult, -} from './post-deploy-config-bundles'; + MANAGED_MEMORY_DEPLOY_NOTICE, + MANAGED_MEMORY_ADD_NOTICE, + hasManagedMemoryHarness, +} from './managed-memory-notice'; // Re-export external requirements for convenience export { diff --git a/src/cli/operations/deploy/managed-memory-notice.ts b/src/cli/operations/deploy/managed-memory-notice.ts new file mode 100644 index 000000000..51699a2c3 --- /dev/null +++ b/src/cli/operations/deploy/managed-memory-notice.ts @@ -0,0 +1,47 @@ +import type { ConfigIO } from '../../../lib'; +import { isGatedFeaturesEnabled } from '../../feature-flags'; + +/** + * One-shot heads-up shown before the CFN apply when a harness uses managed memory. + * Managed-memory harnesses provision a dedicated AgentCore Memory resource during deploy, + * which is the slow part — surface this while it happens so the wait is explained. + * + * Single source of truth for both deploy entry points (CLI command + TUI flow) so the + * wording can't drift between them. + */ +export const MANAGED_MEMORY_DEPLOY_NOTICE = + 'Managed memory: this harness automatically provisions a dedicated AgentCore Memory resource ' + + '(the default unless you set --memory-mode existing or disabled).\n\n' + + 'Memory provisioning can take 3-5 minutes. We know this is slow, and we will be reducing this ' + + 'provisioning time. To skip it, redeploy with --memory-mode disabled.'; + +/** + * Same heads-up worded for `add harness`, where the provisioning hasn't happened yet — it explains + * what the next deploy will do and how to opt out before deploying. + */ +export const MANAGED_MEMORY_ADD_NOTICE = + 'Managed memory: this harness will automatically provision a dedicated AgentCore Memory resource ' + + 'on deploy (the default unless you set --memory-mode existing or disabled).\n\n' + + 'Memory provisioning can take 3-5 minutes. We know this is slow, and we will be reducing this ' + + 'provisioning time. To skip it, recreate the harness with --memory-mode disabled.'; + +/** + * Returns true when the gate is on and at least one harness in the project uses managed memory. + * The memory mode lives in each harness's harness.json (not the agentcore.json pointer list), so + * the per-harness specs are read to detect it. + */ +export async function hasManagedMemoryHarness( + configIO: ConfigIO, + harnesses: { name: string }[] | undefined +): Promise { + if (!isGatedFeaturesEnabled()) { + return false; + } + for (const h of harnesses ?? []) { + const harnessSpec = await configIO.readHarnessSpec(h.name).catch(() => undefined); + if (harnessSpec?.memory?.mode === 'managed') { + return true; + } + } + return false; +} diff --git a/src/cli/operations/deploy/post-deploy-ab-tests.ts b/src/cli/operations/deploy/post-deploy-ab-tests.ts deleted file mode 100644 index aab6aa6ce..000000000 --- a/src/cli/operations/deploy/post-deploy-ab-tests.ts +++ /dev/null @@ -1,721 +0,0 @@ -import type { ABTestDeployedState, AgentCoreProjectSpec, DeployedResourceState } from '../../../schema'; -import { getCredentialProvider } from '../../aws/account'; -import { createABTest, deleteABTest, getABTest, listABTests, updateABTest } from '../../aws/agentcore-ab-tests'; -import type { ABTestEvaluationConfig, ABTestVariant, TrafficAllocationConfig } from '../../aws/agentcore-ab-tests'; -import { arnPrefix } from '../../aws/partition'; -import { - CreateRoleCommand, - DeleteRoleCommand, - DeleteRolePolicyCommand, - GetRoleCommand, - IAMClient, - PutRolePolicyCommand, -} from '@aws-sdk/client-iam'; -import { createHash } from 'node:crypto'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface SetupABTestsOptions { - region: string; - projectSpec: AgentCoreProjectSpec; - existingABTests?: Record; - /** Full deployed resource state for resolving ARN references. */ - deployedResources?: DeployedResourceState; -} - -export interface ABTestSetupResult { - testName: string; - status: 'created' | 'updated' | 'deleted' | 'skipped' | 'error'; - abTestId?: string; - abTestArn?: string; - error?: string; - warning?: string; -} - -export interface SetupABTestsResult { - results: ABTestSetupResult[]; - abTests: Record; - hasErrors: boolean; -} - -// ============================================================================ -// Constants -// ============================================================================ - -const AB_TEST_ROLE_POLICY_NAME = 'ABTestExecutionPolicy'; - -// ============================================================================ -// Config Hash -// ============================================================================ - -/** - * Compute a deterministic SHA-256 hash of the key AB test configuration fields. - * Used to detect whether a redeployment actually changed the test config. - */ -function computeConfigHash(testSpec: { - variants: unknown; - evaluationConfig: unknown; - gatewayRef: string; - gatewayFilter?: unknown; - trafficAllocationConfig?: unknown; -}): string { - const payload = JSON.stringify({ - variants: testSpec.variants, - evaluationConfig: testSpec.evaluationConfig, - gatewayRef: testSpec.gatewayRef, - gatewayFilter: testSpec.gatewayFilter, - trafficAllocationConfig: testSpec.trafficAllocationConfig, - }); - return createHash('sha256').update(payload).digest('hex'); -} - -// ============================================================================ -// Shared Update Helper -// ============================================================================ - -interface ApplyABTestUpdateOptions { - region: string; - abTestId: string; - resolvedVariants: ABTestVariant[]; - resolvedEvalConfig: ABTestEvaluationConfig; - trafficAllocationConfig?: TrafficAllocationConfig; - resolvedRoleArn?: string; - testName: string; - roleCreatedByCli: boolean; - currentHash: string; -} - -async function applyABTestUpdate( - options: ApplyABTestUpdateOptions -): Promise<{ state: ABTestDeployedState; result: ABTestSetupResult }> { - const updateResult = await updateABTest({ - region: options.region, - abTestId: options.abTestId, - variants: options.resolvedVariants, - evaluationConfig: options.resolvedEvalConfig, - trafficAllocationConfig: options.trafficAllocationConfig, - roleArn: options.resolvedRoleArn, - }); - - return { - state: { - abTestId: updateResult.abTestId, - abTestArn: updateResult.abTestArn, - roleArn: options.resolvedRoleArn, - roleCreatedByCli: options.roleCreatedByCli, - configHash: options.currentHash, - }, - result: { - testName: options.testName, - status: 'updated', - abTestId: updateResult.abTestId, - abTestArn: updateResult.abTestArn, - }, - }; -} - -// ============================================================================ -// Implementation -// ============================================================================ - -/** - * Create, update, or delete AB tests post-deploy. - * - * Pattern: - * 1. For each AB test in project spec → resolve ARN references, create or skip - * 2. For each AB test in deployed-state but NOT in project spec → delete (reconciliation) - * 3. Return updated deployed state entries - */ -export async function setupABTests(options: SetupABTestsOptions): Promise { - const { region, projectSpec, existingABTests, deployedResources } = options; - const results: ABTestSetupResult[] = []; - const abTests: Record = {}; - - // Create or skip tests from the spec - for (const testSpec of projectSpec.abTests ?? []) { - let resolvedRoleArn: string | undefined; - let roleCreatedByCli = false; - try { - const currentHash = computeConfigHash(testSpec); - const existingTest = existingABTests?.[testSpec.name]; - - // Resolve ARN references from deployed state - const resolvedVariants = resolveVariants(testSpec.variants, projectSpec.name, deployedResources); - const resolvedGatewayArn = resolveGatewayArn(testSpec.gatewayRef, deployedResources); - if (!resolvedGatewayArn.startsWith('arn:') || resolvedGatewayArn.split(':').length < 6) { - results.push({ - testName: testSpec.name, - status: 'error', - error: `Gateway ARN could not be resolved for AB test "${testSpec.name}". Reference "${testSpec.gatewayRef}" did not match any deployed gateway. Ensure the HTTP gateway was deployed successfully.`, - }); - continue; - } - const resolvedEvalConfig = resolveEvalConfig(testSpec.evaluationConfig, deployedResources); - if (testSpec.roleArn) { - resolvedRoleArn = testSpec.roleArn; - } else { - resolvedRoleArn = await getOrCreateABTestRole({ - region, - projectName: projectSpec.name, - testName: testSpec.name, - gatewayArn: resolvedGatewayArn, - }); - roleCreatedByCli = true; - } - - if (existingTest) { - // Config unchanged — skip to preserve running state - if (existingTest.configHash === currentHash) { - abTests[testSpec.name] = existingTest; - results.push({ - testName: testSpec.name, - status: 'skipped', - abTestId: existingTest.abTestId, - abTestArn: existingTest.abTestArn, - }); - continue; - } - - // Config changed — update in-place instead of delete+recreate - const applied = await applyABTestUpdate({ - region, - abTestId: existingTest.abTestId, - resolvedVariants, - resolvedEvalConfig, - trafficAllocationConfig: testSpec.trafficAllocationConfig as TrafficAllocationConfig | undefined, - resolvedRoleArn, - testName: testSpec.name, - roleCreatedByCli: existingTest.roleCreatedByCli ?? roleCreatedByCli, - currentHash, - }); - abTests[testSpec.name] = applied.state; - results.push(applied.result); - continue; - } - - // Try to find by name via list (handles re-creation after state loss) - const existingByName = await findABTestByName(region, projectSpec.name, testSpec.name); - if (existingByName) { - // Found by name — update in-place with fresh config - const applied = await applyABTestUpdate({ - region, - abTestId: existingByName.abTestId, - resolvedVariants, - resolvedEvalConfig, - trafficAllocationConfig: testSpec.trafficAllocationConfig as TrafficAllocationConfig | undefined, - resolvedRoleArn, - testName: testSpec.name, - roleCreatedByCli, - currentHash, - }); - abTests[testSpec.name] = applied.state; - results.push(applied.result); - continue; - } - - const createOptions = { - region, - name: `${projectSpec.name}_${testSpec.name}`, - description: testSpec.description, - gatewayArn: resolvedGatewayArn, - roleArn: resolvedRoleArn, - variants: resolvedVariants, - evaluationConfig: resolvedEvalConfig, - gatewayFilter: testSpec.gatewayFilter, - trafficAllocationConfig: testSpec.trafficAllocationConfig as TrafficAllocationConfig | undefined, - maxDurationDays: testSpec.maxDurationDays, - enableOnCreate: testSpec.enableOnCreate, - }; - - // Retry on gateway/eval access denied — IAM policy propagation can take time - let result; - const MAX_RETRIES = 5; - const BASE_DELAY_MS = 5_000; - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { - try { - result = await createABTest(createOptions); - break; - } catch (err: unknown) { - const errCode = (err as { name?: string }).name; - const errStatus = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode; - const msg = err instanceof Error ? err.message : String(err); - - const isRetryable = - errCode === 'AccessDeniedException' || - errStatus === 403 || - msg.includes('Access denied') || - msg.includes('Gateway validation error'); - - if (isRetryable && attempt < MAX_RETRIES - 1) { - const delay = BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 1000; - await new Promise(resolve => setTimeout(resolve, delay)); - continue; - } - throw err; - } - } - if (!result) throw new Error('AB test creation failed after retries'); - - abTests[testSpec.name] = { - abTestId: result.abTestId, - abTestArn: result.abTestArn, - roleArn: resolvedRoleArn, - roleCreatedByCli, - configHash: currentHash, - }; - - results.push({ - testName: testSpec.name, - status: 'created', - abTestId: result.abTestId, - abTestArn: result.abTestArn, - }); - } catch (err) { - // Clean up auto-created role on AB test creation failure to avoid orphaned roles - if (roleCreatedByCli && resolvedRoleArn) { - try { - await deleteABTestRole(region, resolvedRoleArn); - } catch { - // Best-effort role cleanup - } - } - results.push({ - testName: testSpec.name, - status: 'error', - error: err instanceof Error ? err.message : String(err), - }); - } - } - - // Orphaned AB tests are deleted by deleteOrphanedABTests() which runs - // as a separate pre-pass before HTTP gateway setup. No deletion loop here. - - return { - results, - abTests, - hasErrors: results.some(r => r.status === 'error'), - }; -} - -/** - * Delete orphaned AB tests (in deployed-state but removed from spec). - * - * AB tests create rules on HTTP gateways, so they must be deleted before - * the gateway can be deleted. Call this before setupHttpGateways. - * - * The main setupABTests deletion loop becomes a no-op for any tests - * already cleaned up here. - */ -export async function deleteOrphanedABTests(options: { - region: string; - projectSpec: AgentCoreProjectSpec; - existingABTests?: Record; -}): Promise<{ results: ABTestSetupResult[]; hasErrors: boolean }> { - const { region, projectSpec, existingABTests } = options; - if (!existingABTests) return { results: [], hasErrors: false }; - - const specTestNames = new Set((projectSpec.abTests ?? []).map(t => t.name)); - const results: ABTestSetupResult[] = []; - - for (const [testName, testState] of Object.entries(existingABTests)) { - if (!specTestNames.has(testName)) { - try { - // Stop the AB test first — running tests cannot be deleted - let wasStopped = false; - let stopTimedOut = false; - try { - await updateABTest({ region, abTestId: testState.abTestId, executionStatus: 'STOPPED' }); - wasStopped = true; - - // Poll until executionStatus is STOPPED (stop is async) - let stopped = false; - for (let i = 0; i < 20; i++) { - const test = await getABTest({ region, abTestId: testState.abTestId }); - if (test.executionStatus === 'STOPPED') { - stopped = true; - break; - } - await new Promise(resolve => setTimeout(resolve, 3_000)); - } - if (!stopped) { - stopTimedOut = true; - } - } catch { - // May already be stopped or in a state that doesn't need stopping — proceed with delete - } - - const deleteResult = await deleteABTest({ - region, - abTestId: testState.abTestId, - }); - - if (deleteResult.success && testState.roleCreatedByCli && testState.roleArn) { - await deleteABTestRole(region, testState.roleArn); - } - - results.push({ - testName, - status: deleteResult.success ? 'deleted' : 'error', - error: deleteResult.error, - warning: stopTimedOut - ? `AB test "${testName}" did not reach STOPPED status within the polling window — proceeding with delete` - : wasStopped - ? `AB test "${testName}" was stopped before deletion` - : undefined, - }); - } catch (err) { - results.push({ - testName, - status: 'error', - error: err instanceof Error ? err.message : String(err), - }); - } - } - } - - return { - results, - hasErrors: results.some(r => r.status === 'error'), - }; -} - -// ============================================================================ -// ARN Resolution Helpers -// ============================================================================ - -async function findABTestByName( - region: string, - projectName: string, - testName: string -): Promise<{ abTestId: string; abTestArn: string } | undefined> { - try { - const prefixedName = `${projectName}_${testName}`; - const result = await listABTests({ region, maxResults: 100 }); - return result.abTests.find( - t => t.name.toLowerCase() === prefixedName.toLowerCase() || t.name.toLowerCase() === testName.toLowerCase() - ); - } catch { - return undefined; - } -} - -/** - * Resolve variant config bundle references. - * If bundleArn is a name (not an ARN), look it up in deployed config bundles. - * Target-based variants have their target name prefixed with projectName to match - * what post-deploy-http-gateways.ts creates on AWS (e.g. `${projectName}-${tgt.name}`). - */ -function resolveVariants( - variants: { - name: 'C' | 'T1'; - weight: number; - variantConfiguration: { - configurationBundle?: { bundleArn: string; bundleVersion: string }; - target?: { targetName: string }; - }; - }[], - projectName: string, - deployedResources?: DeployedResourceState -): ABTestVariant[] { - return variants.map(v => { - const bundle = v.variantConfiguration.configurationBundle; - if (bundle) { - return { - name: v.name, - weight: v.weight, - variantConfiguration: { - configurationBundle: { - bundleArn: resolveConfigBundleArn(bundle.bundleArn, deployedResources), - bundleVersion: resolveConfigBundleVersion(bundle.bundleArn, bundle.bundleVersion, deployedResources), - }, - }, - }; - } - // Target-based variant — prepend projectName to match the AWS-side name created by - // post-deploy-http-gateways.ts: `${projectName}-${tgt.name}` - return { - name: v.name, - weight: v.weight, - variantConfiguration: { - ...(v.variantConfiguration.target && { - target: { name: resolveTargetName(v.variantConfiguration.target.targetName, projectName) }, - }), - }, - }; - }); -} - -function resolveConfigBundleArn(ref: string, deployedResources?: DeployedResourceState): string { - if (ref.startsWith('arn:')) return ref; - - const bundles = deployedResources?.configBundles; - if (bundles?.[ref]) { - return bundles[ref].bundleArn; - } - - return ref; -} - -function resolveConfigBundleVersion( - bundleRef: string, - versionRef: string, - deployedResources?: DeployedResourceState -): string { - if (versionRef !== 'LATEST') return versionRef; - - // Resolve LATEST to the deployed versionId - const bundles = deployedResources?.configBundles; - const name = bundleRef.startsWith('arn:') ? undefined : bundleRef; - if (name && bundles?.[name]) { - return bundles[name].versionId; - } - - return versionRef; -} - -/** - * Resolve a variant target name by applying the project prefix unconditionally. - * post-deploy-http-gateways.ts always creates targets as `${projectName}-${tgt.name}`, - * so the AB test must reference the same prefixed name. - */ -function resolveTargetName(targetName: string, projectName: string): string { - return `${projectName}-${targetName}`; -} - -function resolveGatewayArn(ref: string, deployedResources?: DeployedResourceState): string { - if (ref.startsWith('arn:')) return ref; - - // Check for placeholder pattern {{gateway:}} - const placeholderMatch = /^\{\{gateway:(.+)\}\}$/.exec(ref); - const gwName = placeholderMatch ? placeholderMatch[1] : ref; - - const gateways = deployedResources?.mcp?.gateways; - if (gateways && gwName && gateways[gwName]) { - return gateways[gwName].gatewayArn; - } - - // Check HTTP gateways (imperatively created for A/B testing) - const httpGateways = deployedResources?.httpGateways; - if (httpGateways && gwName && httpGateways[gwName]) { - return httpGateways[gwName].gatewayArn; - } - - return ref; -} - -function resolveEvalConfig( - config: - | { onlineEvaluationConfigArn: string } - | { perVariantOnlineEvaluationConfig: { treatmentName: 'C' | 'T1'; onlineEvaluationConfigArn: string }[] }, - deployedResources?: DeployedResourceState -): ABTestEvaluationConfig { - if ('perVariantOnlineEvaluationConfig' in config) { - // Per-variant eval config — resolve each ARN - return { - perVariantOnlineEvaluationConfig: config.perVariantOnlineEvaluationConfig.map(pv => ({ - name: pv.treatmentName, - onlineEvaluationConfigArn: resolveOnlineEvalArn(pv.onlineEvaluationConfigArn, deployedResources), - })), - }; - } - - const ref = config.onlineEvaluationConfigArn; - return { onlineEvaluationConfigArn: resolveOnlineEvalArn(ref, deployedResources) }; -} - -function resolveOnlineEvalArn(ref: string, deployedResources?: DeployedResourceState): string { - if (ref.startsWith('arn:')) return ref; - - const configs = deployedResources?.onlineEvalConfigs; - if (configs?.[ref]) { - return configs[ref].onlineEvaluationConfigArn; - } - - return ref; -} - -// ============================================================================ -// IAM Role Management -// ============================================================================ - -/** - * Generate a project-scoped role name following the CDK pattern: - * AgentCore-{ProjectName}-ABTest{TestName}-{Hash} - */ -function generateRoleName(projectName: string, testName: string): string { - // Deterministic hash so retries produce the same role name (avoids orphaned roles) - const hash = createHash('sha256').update(`${projectName}:${testName}`).digest('hex').slice(0, 8); - const base = `AgentCore-${projectName}-ABTest${testName}`; - // IAM role names max 64 chars - return `${base.slice(0, 55)}-${hash}`; -} - -/** - * Extract role name from ARN: arn:aws:iam::123456789012:role/RoleName → RoleName - */ -function roleNameFromArn(roleArn: string): string { - const parts = roleArn.split('/'); - return parts[parts.length - 1] ?? roleArn; -} - -interface CreateABTestRoleOptions { - region: string; - projectName: string; - testName: string; - gatewayArn: string; -} - -async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise { - const { region, projectName, testName, gatewayArn } = options; - const credentials = getCredentialProvider(); - const iamClient = new IAMClient({ region, credentials }); - - // Extract account ID from gateway ARN (arn:aws:bedrock-agentcore:REGION:ACCOUNT:gateway/ID) - const accountId = gatewayArn.split(':')[4] ?? '*'; - - const roleName = generateRoleName(projectName, testName); - - const trustPolicy = JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Principal: { Service: 'bedrock-agentcore.amazonaws.com' }, - Action: 'sts:AssumeRole', - Condition: { - StringEquals: { 'aws:SourceAccount': accountId }, - ArnLike: { 'aws:SourceArn': `${arnPrefix(region)}:bedrock-agentcore:*:${accountId}:ab-test/*` }, - }, - }, - ], - }); - - let roleArn: string; - let _needsPropagationWait = false; - - try { - const createResult = await iamClient.send( - new CreateRoleCommand({ - RoleName: roleName, - AssumeRolePolicyDocument: trustPolicy, - Description: `Auto-created execution role for AgentCore AB test: ${testName}`, - Tags: [ - { Key: 'agentcore:created-by', Value: 'agentcore-cli' }, - { Key: 'agentcore:project-name', Value: projectName }, - { Key: 'agentcore:ab-test-name', Value: testName }, - ], - }) - ); - - roleArn = createResult.Role?.Arn ?? ''; - if (!roleArn) { - throw new Error(`IAM CreateRole succeeded but returned no role ARN for "${roleName}"`); - } - _needsPropagationWait = true; - } catch (err: unknown) { - // Handle retry after a previous failed deploy left the role behind - const errName = (err as { name?: string }).name; - if (errName === 'EntityAlreadyExistsException') { - // IAM role already exists — reuse it - const existing = await iamClient.send(new GetRoleCommand({ RoleName: roleName })); - roleArn = existing.Role?.Arn ?? ''; - if (!roleArn) { - throw new Error(`Role "${roleName}" already exists but ARN could not be retrieved`); - } - } else { - throw err; - } - } - - const policy = JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Sid: 'AgentCoreResources', - Effect: 'Allow', - Action: [ - 'bedrock-agentcore:GetGateway', - 'bedrock-agentcore:GetGatewayTarget', - 'bedrock-agentcore:ListGatewayTargets', - 'bedrock-agentcore:CreateGatewayRule', - 'bedrock-agentcore:UpdateGatewayRule', - 'bedrock-agentcore:GetGatewayRule', - 'bedrock-agentcore:DeleteGatewayRule', - 'bedrock-agentcore:ListGatewayRules', - 'bedrock-agentcore:GetOnlineEvaluationConfig', - 'bedrock-agentcore:GetEvaluator', - 'bedrock-agentcore:GetConfigurationBundle', - 'bedrock-agentcore:GetConfigurationBundleVersion', - 'bedrock-agentcore:ListConfigurationBundleVersions', - ], - Resource: `${arnPrefix(region)}:bedrock-agentcore:*:${accountId}:*`, - Condition: { StringEquals: { 'aws:ResourceAccount': accountId } }, - }, - { - Sid: 'CloudWatchLogsDescribe', - Effect: 'Allow', - Action: ['logs:DescribeLogGroups'], - Resource: '*', - }, - { - Sid: 'CloudWatchLogs', - Effect: 'Allow', - Action: [ - 'logs:DescribeIndexPolicies', - 'logs:PutIndexPolicy', - 'logs:StartQuery', - 'logs:GetQueryResults', - 'logs:StopQuery', - 'logs:FilterLogEvents', - 'logs:GetLogEvents', - ], - Resource: [ - `${arnPrefix(region)}:logs:*:${accountId}:log-group:/aws/bedrock-agentcore/evaluations/*`, - `${arnPrefix(region)}:logs:*:${accountId}:log-group:/aws/bedrock-agentcore/runtimes/*`, - `${arnPrefix(region)}:logs:*:${accountId}:log-group:aws/spans`, - `${arnPrefix(region)}:logs:*:${accountId}:log-group:aws/spans:*`, - ], - }, - ], - }); - - // Re-apply the inline policy (idempotent — covers both new and recovered roles) - await iamClient.send( - new PutRolePolicyCommand({ - RoleName: roleName, - PolicyName: AB_TEST_ROLE_POLICY_NAME, - PolicyDocument: policy, - }) - ); - - // Always wait for IAM policy propagation — both new roles and policy updates on existing roles - await new Promise(resolve => setTimeout(resolve, 15_000)); - - return roleArn; -} - -async function deleteABTestRole(region: string, roleArn: string): Promise { - const credentials = getCredentialProvider(); - const iamClient = new IAMClient({ region, credentials }); - const roleName = roleNameFromArn(roleArn); - - try { - // Must delete inline policies before deleting the role - await iamClient.send( - new DeleteRolePolicyCommand({ - RoleName: roleName, - PolicyName: AB_TEST_ROLE_POLICY_NAME, - }) - ); - } catch { - // Policy may not exist - } - - try { - await iamClient.send(new DeleteRoleCommand({ RoleName: roleName })); - } catch { - // Role may already be deleted or in use — best effort - } -} diff --git a/src/cli/operations/deploy/post-deploy-config-bundles.ts b/src/cli/operations/deploy/post-deploy-config-bundles.ts deleted file mode 100644 index 5318c54b1..000000000 --- a/src/cli/operations/deploy/post-deploy-config-bundles.ts +++ /dev/null @@ -1,348 +0,0 @@ -import type { AgentCoreProjectSpec, ConfigBundleDeployedState, DeployedState } from '../../../schema'; -import { - createConfigurationBundle, - deleteConfigurationBundle, - getConfigurationBundleVersion, - listConfigurationBundleVersions, - listConfigurationBundles, - updateConfigurationBundle, -} from '../../aws/agentcore-config-bundles'; -import type { ComponentConfigurationMap } from '../../aws/agentcore-config-bundles'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface SetupConfigBundlesOptions { - region: string; - projectSpec: AgentCoreProjectSpec; - /** Existing config bundle deployed state (from deployed-state.json) */ - existingBundles?: Record; -} - -export interface ConfigBundleSetupResult { - bundleName: string; - status: 'created' | 'updated' | 'deleted' | 'skipped' | 'error'; - bundleId?: string; - bundleArn?: string; - versionId?: string; - error?: string; -} - -export interface SetupConfigBundlesResult { - results: ConfigBundleSetupResult[]; - /** Deployed state entries for config bundles (to merge into deployed-state.json) */ - configBundles: Record; - hasErrors: boolean; -} - -// ============================================================================ -// Implementation -// ============================================================================ - -/** - * Create, update, or delete configuration bundles post-deploy. - * - * Pattern: - * 1. For each configBundle in project spec → create or update - * 2. For each bundle in deployed-state but NOT in project spec → delete (reconciliation) - * 3. Return updated deployed state entries - */ -export async function setupConfigBundles(options: SetupConfigBundlesOptions): Promise { - const { region, projectSpec, existingBundles } = options; - const results: ConfigBundleSetupResult[] = []; - const configBundles: Record = {}; - - const specBundleNames = new Set((projectSpec.configBundles ?? []).map(b => b.name)); - const projectName = projectSpec.name; - - // Create or update bundles from the spec - for (const bundleSpec of projectSpec.configBundles ?? []) { - // Prepend project name to the API-side bundle name (no separator for config bundles) - const apiBundleName = `${projectName}${bundleSpec.name}`; - - try { - // Try to update if we have an existing bundle ID - const existingBundle = existingBundles?.[bundleSpec.name]; - let updated = false; - - if (existingBundle) { - try { - // Fetch the exact version we know about — avoids branch-not-found errors - const current = await getConfigurationBundleVersion({ - region, - bundleId: existingBundle.bundleId, - versionId: existingBundle.versionId, - }); - const componentsChanged = !deepEqual(current.components, bundleSpec.components); - const descriptionChanged = (bundleSpec.description ?? undefined) !== (current.description ?? undefined); - - if (!componentsChanged && !descriptionChanged) { - // Nothing changed — skip the update, preserve existing state - configBundles[bundleSpec.name] = { - bundleId: existingBundle.bundleId, - bundleArn: existingBundle.bundleArn, - versionId: existingBundle.versionId, - }; - results.push({ - bundleName: bundleSpec.name, - status: 'skipped', - bundleId: existingBundle.bundleId, - bundleArn: existingBundle.bundleArn, - versionId: existingBundle.versionId, - }); - updated = true; - } else { - // Use the branch from the spec, or fall back to whatever branch the API has - const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'mainline'; - const result = await updateConfigurationBundle({ - region, - bundleId: existingBundle.bundleId, - description: bundleSpec.description, - components: bundleSpec.components as ComponentConfigurationMap, - parentVersionIds: [current.versionId], - branchName: effectiveBranch, - commitMessage: bundleSpec.commitMessage ?? `Update ${bundleSpec.name}`, - }); - - configBundles[bundleSpec.name] = { - bundleId: result.bundleId, - bundleArn: result.bundleArn, - versionId: result.versionId, - }; - - results.push({ - bundleName: bundleSpec.name, - status: 'updated', - bundleId: result.bundleId, - bundleArn: result.bundleArn, - versionId: result.versionId, - }); - updated = true; - } - } catch (updateErr) { - // If bundle or branch not found, fall through to find-by-name or create - const msg = updateErr instanceof Error ? updateErr.message : String(updateErr); - if (!msg.includes('404') && !msg.includes('not found')) throw updateErr; - } - } - - if (!updated) { - // Try to find by name via list (handles re-creation after state loss) - const existingByName = await findBundleByName(region, apiBundleName); - - if (existingByName) { - // Fetch versions and pick the newest — avoids branch-not-found errors from getConfigurationBundle - const versions = await listConfigurationBundleVersions({ - region, - bundleId: existingByName.bundleId, - }); - const sorted = [...versions.versions].sort((a, b) => Number(b.versionCreatedAt) - Number(a.versionCreatedAt)); - const latestVersionId = sorted[0]?.versionId; - if (!latestVersionId) throw new Error(`No versions found for bundle ${bundleSpec.name}`); - const current = await getConfigurationBundleVersion({ - region, - bundleId: existingByName.bundleId, - versionId: latestVersionId, - }); - const componentsChanged = !deepEqual(current.components, bundleSpec.components); - const descriptionChanged = (bundleSpec.description ?? undefined) !== (current.description ?? undefined); - - if (!componentsChanged && !descriptionChanged) { - configBundles[bundleSpec.name] = { - bundleId: existingByName.bundleId, - bundleArn: current.bundleArn, - versionId: current.versionId, - }; - results.push({ - bundleName: bundleSpec.name, - status: 'skipped', - bundleId: existingByName.bundleId, - bundleArn: current.bundleArn, - versionId: current.versionId, - }); - } else { - const effectiveBranch = bundleSpec.branchName ?? current.lineageMetadata?.branchName ?? 'mainline'; - const result = await updateConfigurationBundle({ - region, - bundleId: existingByName.bundleId, - description: bundleSpec.description, - components: bundleSpec.components as ComponentConfigurationMap, - parentVersionIds: [current.versionId], - branchName: effectiveBranch, - commitMessage: bundleSpec.commitMessage ?? `Update ${bundleSpec.name}`, - }); - - configBundles[bundleSpec.name] = { - bundleId: result.bundleId, - bundleArn: result.bundleArn, - versionId: result.versionId, - }; - - results.push({ - bundleName: bundleSpec.name, - status: 'updated', - bundleId: result.bundleId, - bundleArn: result.bundleArn, - versionId: result.versionId, - }); - } - } else { - // Create new — omit branchName if not in spec so the API uses its default - const result = await createConfigurationBundle({ - region, - bundleName: apiBundleName, - description: bundleSpec.description, - components: bundleSpec.components as ComponentConfigurationMap, - branchName: bundleSpec.branchName, - commitMessage: bundleSpec.commitMessage ?? `Create ${bundleSpec.name}`, - }); - - configBundles[bundleSpec.name] = { - bundleId: result.bundleId, - bundleArn: result.bundleArn, - versionId: result.versionId, - }; - - results.push({ - bundleName: bundleSpec.name, - status: 'created', - bundleId: result.bundleId, - bundleArn: result.bundleArn, - versionId: result.versionId, - }); - } - } - } catch (err) { - results.push({ - bundleName: bundleSpec.name, - status: 'error', - error: err instanceof Error ? err.message : String(err), - }); - } - } - - // Delete orphaned bundles (in deployed-state but removed from spec) - if (existingBundles) { - for (const [bundleName, bundleState] of Object.entries(existingBundles)) { - if (!specBundleNames.has(bundleName)) { - try { - await deleteConfigurationBundle({ - region, - bundleId: bundleState.bundleId, - }); - - results.push({ - bundleName, - status: 'deleted', - }); - } catch (err) { - results.push({ - bundleName, - status: 'error', - error: err instanceof Error ? err.message : String(err), - }); - } - } - } - } - - return { - results, - configBundles, - hasErrors: results.some(r => r.status === 'error'), - }; -} - -// ============================================================================ -// Helpers -// ============================================================================ - -async function findBundleByName(region: string, bundleName: string): Promise<{ bundleId: string } | undefined> { - try { - const result = await listConfigurationBundles({ region, maxResults: 100 }); - return result.bundles.find(b => b.bundleName === bundleName); - } catch { - return undefined; - } -} - -/** Key-order-independent deep-equal for JSON-serializable objects. */ -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (a === null || b === null || typeof a !== typeof b) return false; - if (typeof a !== 'object') return false; - - if (Array.isArray(a)) { - if (!Array.isArray(b) || a.length !== b.length) return false; - return a.every((item, i) => deepEqual(item, b[i])); - } - - const aObj = a as Record; - const bObj = b as Record; - const aKeys = Object.keys(aObj); - const bKeys = Object.keys(bObj); - if (aKeys.length !== bKeys.length) return false; - return aKeys.every(key => key in bObj && deepEqual(aObj[key], bObj[key])); -} - -// ============================================================================ -// Component Key Resolution -// ============================================================================ - -/** - * Resolve placeholder component keys (e.g., {{runtime:name}}, {{gateway:name}}) - * to actual ARNs from deployed state. - */ -export function resolveConfigBundleComponentKeys( - projectSpec: AgentCoreProjectSpec, - deployedState: DeployedState, - targetName: string -): AgentCoreProjectSpec { - const resources = deployedState.targets?.[targetName]?.resources; - if (!resources) return projectSpec; - - const resolvedBundles = (projectSpec.configBundles ?? []).map(bundle => { - const resolvedComponents: Record }> = {}; - - for (const [key, value] of Object.entries(bundle.components ?? {})) { - const resolvedKey = resolveComponentKey(key, resources); - resolvedComponents[resolvedKey] = value; - } - - return { ...bundle, components: resolvedComponents }; - }); - - return { ...projectSpec, configBundles: resolvedBundles }; -} - -function resolveComponentKey( - key: string, - resources: NonNullable -): string { - if (key.startsWith('arn:')) return key; - - const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(key); - if (gwMatch) { - const gwName = gwMatch[1]!; - const httpGw = resources.httpGateways?.[gwName]; - if (httpGw) return httpGw.gatewayArn; - const mcpGw = resources.mcp?.gateways?.[gwName]; - if (mcpGw) return mcpGw.gatewayArn; - throw new Error( - `Config bundle references gateway "${gwName}" but it was not found in deployed resources. Ensure the gateway is defined in agentcore.json and deploys successfully.` - ); - } - - const rtMatch = /^\{\{runtime:(.+)\}\}$/.exec(key); - if (rtMatch) { - const rtName = rtMatch[1]!; - const rt = resources.runtimes?.[rtName]; - if (rt) return rt.runtimeArn; - throw new Error( - `Config bundle references runtime "${rtName}" but it was not found in deployed resources. Ensure the runtime is defined in agentcore.json and deploys successfully.` - ); - } - - return key; -} diff --git a/src/cli/operations/deploy/post-deploy-http-gateways.ts b/src/cli/operations/deploy/post-deploy-http-gateways.ts deleted file mode 100644 index d59a62bdf..000000000 --- a/src/cli/operations/deploy/post-deploy-http-gateways.ts +++ /dev/null @@ -1,652 +0,0 @@ -import type { AgentCoreProjectSpec, DeployedResourceState, HttpGatewayDeployedState } from '../../../schema'; -import { getCredentialProvider } from '../../aws/account'; -import { - createHttpGateway, - createHttpGatewayTarget, - deleteHttpGateway, - deleteHttpGatewayTarget, - getHttpGatewayTarget, - listAllHttpGateways, - listHttpGatewayTargets, - waitForGatewayReady, - waitForTargetReady, -} from '../../aws/agentcore-http-gateways'; -import { - CreateRoleCommand, - DeleteRoleCommand, - DeleteRolePolicyCommand, - GetRoleCommand, - IAMClient, - PutRolePolicyCommand, -} from '@aws-sdk/client-iam'; -import { createHash } from 'node:crypto'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface SetupHttpGatewaysOptions { - region: string; - projectName: string; - projectSpec: AgentCoreProjectSpec; - existingHttpGateways?: Record; - deployedResources?: DeployedResourceState; -} - -export interface HttpGatewaySetupResult { - gatewayName: string; - status: 'created' | 'skipped' | 'deleted' | 'error'; - gatewayId?: string; - gatewayArn?: string; - error?: string; -} - -export interface SetupHttpGatewaysResult { - results: HttpGatewaySetupResult[]; - httpGateways: Record; - hasErrors: boolean; -} - -// ============================================================================ -// Constants -// ============================================================================ - -const HTTP_GATEWAY_ROLE_POLICY_NAME = 'HttpGatewayExecutionPolicy'; - -// ============================================================================ -// Implementation -// ============================================================================ - -/** - * Create or delete HTTP gateways post-deploy. - * - * Pattern: - * 1. For each httpGateway in project spec -> resolve runtime ARN, create or skip - * 2. For each httpGateway in deployed-state but NOT in project spec -> delete (reconciliation) - * 3. Return updated deployed state entries - */ -export async function setupHttpGateways(options: SetupHttpGatewaysOptions): Promise { - const { region, projectName, projectSpec, existingHttpGateways, deployedResources } = options; - const results: HttpGatewaySetupResult[] = []; - const httpGateways: Record = {}; - - // Defensive: Zod .default([]) only fires on undefined, not null. - // If someone has "httpGateways": null in their JSON, it passes through as null. - const httpGatewaySpecs = projectSpec.httpGateways ?? []; - - // Create or skip gateways from the spec - for (const gwSpec of httpGatewaySpecs) { - let resolvedRoleArn: string | undefined; - let roleCreatedByCli = false; - try { - const existingGateway = existingHttpGateways?.[gwSpec.name]; - - if (existingGateway) { - // Already deployed - - // Create or update targets from httpGateways[].targets (for target-based AB testing) - if (gwSpec.targets && gwSpec.targets.length > 0) { - // List existing targets to avoid unnecessary create calls - const existingTargetsByName = new Map(); - try { - const existingTargets = await listHttpGatewayTargets({ - region, - gatewayId: existingGateway.gatewayId, - }); - for (const t of existingTargets.targets) { - existingTargetsByName.set(t.name, { targetId: t.targetId }); - } - } catch { - // If list fails, fall through and let create handle 409s - } - - for (const tgt of gwSpec.targets) { - const existingTarget = existingTargetsByName.get(`${projectName}-${tgt.name}`); - if (existingTarget) { - // Target exists by name — check if qualifier matches - try { - const targetDetails = await getHttpGatewayTarget({ - region, - gatewayId: existingGateway.gatewayId, - targetId: existingTarget.targetId, - }); - const httpConfig = ( - targetDetails.targetConfiguration as - | { - http?: { - agentcoreRuntime?: { qualifier?: string }; - runtimeTargetConfiguration?: { qualifier?: string }; - }; - } - | undefined - )?.http; - const existingQualifier = - httpConfig?.agentcoreRuntime?.qualifier ?? httpConfig?.runtimeTargetConfiguration?.qualifier; - const specQualifier = tgt.qualifier ?? 'DEFAULT'; - if (existingQualifier === specQualifier) { - // Qualifier matches — skip - continue; - } - // Qualifier differs — delete old target and recreate - await deleteHttpGatewayTarget({ - region, - gatewayId: existingGateway.gatewayId, - targetId: existingTarget.targetId, - }); - } catch { - // If get/delete fails, fall through to create which will handle conflicts - } - } - try { - const tgtRuntime = deployedResources?.runtimes?.[tgt.runtimeRef]; - if (!tgtRuntime) continue; - const tgtResult = await createHttpGatewayTarget({ - region, - gatewayId: existingGateway.gatewayId, - targetName: `${projectName}-${tgt.name}`, - runtimeArn: tgtRuntime.runtimeArn, - qualifier: tgt.qualifier, - }); - await waitForTargetReady({ - region, - gatewayId: existingGateway.gatewayId, - targetId: tgtResult.targetId, - }); - } catch (tgtErr) { - if (tgtErr instanceof Error && tgtErr.message.includes('409')) continue; - // Non-fatal - } - } - } - - httpGateways[gwSpec.name] = existingGateway; - results.push({ - gatewayName: gwSpec.name, - status: 'skipped', - gatewayId: existingGateway.gatewayId, - gatewayArn: existingGateway.gatewayArn, - }); - continue; - } - - // Try to find by name via list (handles re-creation after state loss) - const prefixedGatewayName = `${projectName}-${gwSpec.name}`; - const existingByName = await findHttpGatewayByName(region, prefixedGatewayName); - if (existingByName) { - console.warn( - `Warning: HTTP gateway "${gwSpec.name}" found by name but local state was lost. Target and role state may be incomplete — consider re-deploying.` - ); - httpGateways[gwSpec.name] = { - gatewayId: existingByName.gatewayId, - gatewayArn: existingByName.gatewayArn, - // targetId, roleArn, roleCreatedByCli unknown after state-loss recovery - }; - results.push({ - gatewayName: gwSpec.name, - status: 'skipped', - gatewayId: existingByName.gatewayId, - gatewayArn: existingByName.gatewayArn, - }); - continue; - } - - // Migration fallback: try unprefixed name for pre-PR gateways (Comment 3 fix) - const existingByLegacyName = await findHttpGatewayByName(region, gwSpec.name); - if (existingByLegacyName) { - console.warn( - `Warning: HTTP gateway "${gwSpec.name}" was found using its pre-migration name. ` + - `This CLI version uses the naming convention "${prefixedGatewayName}". ` + - `The gateway has been recovered from state loss. ` + - `You may want to rename "${gwSpec.name}" to "${prefixedGatewayName}" on AWS to match the new convention.` - ); - httpGateways[gwSpec.name] = { - gatewayId: existingByLegacyName.gatewayId, - gatewayArn: existingByLegacyName.gatewayArn, - // targetId, roleArn, roleCreatedByCli unknown after state-loss recovery - }; - results.push({ - gatewayName: gwSpec.name, - status: 'skipped', - gatewayId: existingByLegacyName.gatewayId, - gatewayArn: existingByLegacyName.gatewayArn, - }); - continue; - } - - // Resolve runtime ARN from deployed state - const runtimeState = deployedResources?.runtimes?.[gwSpec.runtimeRef]; - if (!runtimeState) { - results.push({ - gatewayName: gwSpec.name, - status: 'error', - error: `Runtime "${gwSpec.runtimeRef}" not found in deployed resources. Deploy the runtime before creating an HTTP gateway.`, - }); - continue; - } - const runtimeArn = runtimeState.runtimeArn; - if (gwSpec.roleArn) { - resolvedRoleArn = gwSpec.roleArn; - } else { - resolvedRoleArn = await getOrCreateHttpGatewayRole({ - region, - projectName, - gatewayName: gwSpec.name, - runtimeArn, - }); - roleCreatedByCli = true; - } - - // Create gateway and wait for it to become READY before adding targets - // Creating HTTP gateway for runtime - const createResult = await createHttpGateway({ - region, - name: `${projectName}-${gwSpec.name}`, - roleArn: resolvedRoleArn, - }); - - const readyGateway = await waitForGatewayReady({ - region, - gatewayId: createResult.gatewayId, - }); - - // Create target pointing to the runtime - let targetId: string | undefined; - try { - const targetResult = await createHttpGatewayTarget({ - region, - gatewayId: createResult.gatewayId, - targetName: `${projectName}-${gwSpec.runtimeRef}`, - runtimeArn, - }); - - targetId = targetResult.targetId; - - // Wait for target to become ready - // Waiting for gateway target to become ready - await waitForTargetReady({ - region, - gatewayId: createResult.gatewayId, - targetId: targetResult.targetId, - }); - } catch (targetErr) { - // Rollback: delete target (if created), wait for deletion, then delete gateway - try { - if (targetId) { - await deleteHttpGatewayTarget({ region, gatewayId: createResult.gatewayId, targetId }); - } - } catch { - // Best-effort target cleanup - } - try { - await deleteHttpGateway({ region, gatewayId: createResult.gatewayId }); - } catch { - // Best-effort gateway rollback - } - - // Always clean up auto-created role on target failure, regardless of gateway rollback result - if (roleCreatedByCli && resolvedRoleArn) { - try { - await deleteHttpGatewayRole(region, resolvedRoleArn); - } catch { - // Best-effort role cleanup - } - } - - results.push({ - gatewayName: gwSpec.name, - status: 'error', - error: `Target creation failed, gateway rolled back: ${targetErr instanceof Error ? targetErr.message : String(targetErr)}`, - }); - continue; - } - - // Create additional targets from httpGateways[].targets (for target-based AB testing) - if (gwSpec.targets && gwSpec.targets.length > 0) { - for (const tgt of gwSpec.targets) { - try { - const tgtRuntime = deployedResources?.runtimes?.[tgt.runtimeRef]; - if (!tgtRuntime) { - // Runtime not deployed, skip this target - continue; - } - const tgtResult = await createHttpGatewayTarget({ - region, - gatewayId: createResult.gatewayId, - targetName: `${projectName}-${tgt.name}`, - runtimeArn: tgtRuntime.runtimeArn, - qualifier: tgt.qualifier, - }); - await waitForTargetReady({ - region, - gatewayId: createResult.gatewayId, - targetId: tgtResult.targetId, - }); - } catch (tgtErr) { - // 409 = already exists, skip - if (tgtErr instanceof Error && tgtErr.message.includes('409')) continue; - // Non-fatal: log but continue - } - } - } - - httpGateways[gwSpec.name] = { - gatewayId: createResult.gatewayId, - gatewayArn: createResult.gatewayArn, - gatewayUrl: readyGateway.gatewayUrl, - targetId, - roleArn: resolvedRoleArn, - roleCreatedByCli, - }; - - results.push({ - gatewayName: gwSpec.name, - status: 'created', - gatewayId: createResult.gatewayId, - gatewayArn: createResult.gatewayArn, - }); - } catch (err) { - // If we auto-created a role, clean it up on failure - if (roleCreatedByCli && resolvedRoleArn) { - try { - await deleteHttpGatewayRole(region, resolvedRoleArn); - } catch { - // Best-effort role cleanup - } - } - results.push({ - gatewayName: gwSpec.name, - status: 'error', - error: err instanceof Error ? err.message : String(err), - }); - } - } - - // Orphaned gateways are deleted by deleteOrphanedHttpGateways() which runs - // as a separate pre-pass. No deletion loop here. - - return { - results, - httpGateways, - hasErrors: results.some(r => r.status === 'error'), - }; -} - -// ============================================================================ -// Shared Gateway Deletion -// ============================================================================ - -/** - * Delete an HTTP gateway and all its targets. Best-effort — target failures - * are warned but don't prevent gateway deletion attempt. - * - * Order: targets → gateway → role - */ -export async function deleteHttpGatewayWithTargets(options: { - region: string; - gatewayId: string; - gatewayName: string; - knownTargetId?: string; - roleArn?: string; - roleCreatedByCli?: boolean; -}): Promise<{ success: boolean; error?: string }> { - const { region, gatewayId, gatewayName, knownTargetId, roleArn, roleCreatedByCli } = options; - - const targetIds: string[] = []; - if (knownTargetId) { - targetIds.push(knownTargetId); - } - try { - const targets = await listHttpGatewayTargets({ region, gatewayId, maxResults: 100 }); - for (const t of targets.targets) { - if (!targetIds.includes(t.targetId)) { - targetIds.push(t.targetId); - } - } - } catch { - // Best-effort — proceed with whatever IDs we have - } - - for (const targetId of targetIds) { - try { - await deleteHttpGatewayTarget({ region, gatewayId, targetId }); - } catch (err) { - console.warn( - `Warning: Failed to delete target ${targetId} on gateway "${gatewayName}": ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - const deleteResult = await deleteHttpGateway({ region, gatewayId }); - if (!deleteResult.success) { - return { success: false, error: deleteResult.error }; - } - - if (roleCreatedByCli && roleArn) { - try { - await deleteHttpGatewayRole(region, roleArn); - } catch { - // Best-effort role cleanup - } - } - - return { success: true }; -} - -/** - * Delete orphaned HTTP gateways (in deployed-state but removed from spec). - * Call before setupHttpGateways. - */ -export async function deleteOrphanedHttpGateways(options: { - region: string; - projectSpec: AgentCoreProjectSpec; - existingHttpGateways?: Record; -}): Promise<{ results: HttpGatewaySetupResult[]; hasErrors: boolean }> { - const { region, projectSpec, existingHttpGateways } = options; - if (!existingHttpGateways) return { results: [], hasErrors: false }; - - const specGatewayNames = new Set((projectSpec.httpGateways ?? []).map(g => g.name)); - const results: HttpGatewaySetupResult[] = []; - - for (const [gwName, gwState] of Object.entries(existingHttpGateways)) { - if (!specGatewayNames.has(gwName)) { - try { - const result = await deleteHttpGatewayWithTargets({ - region, - gatewayId: gwState.gatewayId, - gatewayName: gwName, - knownTargetId: gwState.targetId, - roleArn: gwState.roleArn, - roleCreatedByCli: gwState.roleCreatedByCli, - }); - - results.push({ - gatewayName: gwName, - status: result.success ? 'deleted' : 'error', - error: result.error, - }); - } catch (err) { - results.push({ - gatewayName: gwName, - status: 'error', - error: err instanceof Error ? err.message : String(err), - }); - } - } - } - - return { - results, - hasErrors: results.some(r => r.status === 'error'), - }; -} - -// ============================================================================ -// Gateway Trace Delivery -// ============================================================================ - -// ============================================================================ -// Helpers -// ============================================================================ - -async function findHttpGatewayByName( - region: string, - name: string -): Promise<{ gatewayId: string; gatewayArn: string } | undefined> { - try { - const gateways = await listAllHttpGateways({ region }); - return gateways.find(gw => gw.name === name); - } catch (err) { - console.warn( - `Warning: Could not list HTTP gateways to check for existing "${name}": ${err instanceof Error ? err.message : String(err)}` - ); - return undefined; - } -} - -// ============================================================================ -// IAM Role Management -// ============================================================================ - -/** - * Generate a project-scoped role name following the CDK pattern: - * AgentCore-{ProjectName}-HttpGw{GatewayName}-{Hash} - */ -function generateRoleName(projectName: string, gatewayName: string): string { - const base = `AgentCore-${projectName}-HttpGw${gatewayName}`; - // Use deterministic hash so retries produce the same role name - const hash = createHash('sha256').update(`${projectName}:${gatewayName}`).digest('hex').slice(0, 8); - // IAM role names max 64 chars - return `${base.slice(0, 55)}-${hash}`; -} - -/** - * Extract role name from ARN: arn:aws:iam::123456789012:role/RoleName -> RoleName - */ -function roleNameFromArn(roleArn: string): string { - const parts = roleArn.split('/'); - return parts[parts.length - 1] ?? roleArn; -} - -interface CreateHttpGatewayRoleOptions { - region: string; - projectName: string; - gatewayName: string; - runtimeArn: string; -} - -async function getOrCreateHttpGatewayRole(options: CreateHttpGatewayRoleOptions): Promise { - const { region, projectName, gatewayName } = options; - const credentials = getCredentialProvider(); - const iamClient = new IAMClient({ region, credentials }); - - const roleName = generateRoleName(projectName, gatewayName); - - const trustPolicy = JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Principal: { Service: 'bedrock-agentcore.amazonaws.com' }, - Action: 'sts:AssumeRole', - }, - ], - }); - - const policy = JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Sid: 'InvokeRuntimeStatement', - Effect: 'Allow', - Action: [ - 'bedrock-agentcore:InvokeRuntime', - 'bedrock-agentcore:InvokeAgent', - 'bedrock-agentcore:InvokeAgentRuntime', - ], - // Resource must be '*' because the gateway service invokes runtimes using - // a resource identifier that doesn't match the deployed runtime ARN format. - // This matches the A/B testing guide's gateway role policy. - Resource: '*', - }, - ], - }); - - let roleArn: string; - let needsPropagationWait = false; - - try { - const createResult = await iamClient.send( - new CreateRoleCommand({ - RoleName: roleName, - AssumeRolePolicyDocument: trustPolicy, - Description: `Auto-created execution role for AgentCore HTTP gateway: ${gatewayName}`, - Tags: [ - { Key: 'agentcore:created-by', Value: 'agentcore-cli' }, - { Key: 'agentcore:project-name', Value: projectName }, - { Key: 'agentcore:http-gateway-name', Value: gatewayName }, - ], - }) - ); - - roleArn = createResult.Role?.Arn ?? ''; - if (!roleArn) { - throw new Error(`IAM CreateRole succeeded but returned no role ARN for "${roleName}"`); - } - needsPropagationWait = true; - } catch (err: unknown) { - // Handle retry after a previous failed deploy left the role behind - const errName = (err as { name?: string }).name; - if (errName === 'EntityAlreadyExistsException') { - // IAM role already exists — reusing - const existing = await iamClient.send(new GetRoleCommand({ RoleName: roleName })); - roleArn = existing.Role?.Arn ?? ''; - if (!roleArn) { - throw new Error(`Role "${roleName}" already exists but ARN could not be retrieved`); - } - } else { - throw new Error( - `Failed to create IAM role "${roleName}" for HTTP gateway "${gatewayName}": ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - // Re-apply the inline policy (idempotent — covers both new and recovered roles) - await iamClient.send( - new PutRolePolicyCommand({ - RoleName: roleName, - PolicyName: HTTP_GATEWAY_ROLE_POLICY_NAME, - PolicyDocument: policy, - }) - ); - - if (needsPropagationWait) { - // Waiting for IAM role propagation (~15s) - await new Promise(resolve => setTimeout(resolve, 15_000)); - } - - return roleArn; -} - -export async function deleteHttpGatewayRole(region: string, roleArn: string): Promise { - const credentials = getCredentialProvider(); - const iamClient = new IAMClient({ region, credentials }); - const roleName = roleNameFromArn(roleArn); - - try { - // Must delete inline policies before deleting the role - await iamClient.send( - new DeleteRolePolicyCommand({ - RoleName: roleName, - PolicyName: HTTP_GATEWAY_ROLE_POLICY_NAME, - }) - ); - } catch { - // Policy may not exist - } - - try { - await iamClient.send(new DeleteRoleCommand({ RoleName: roleName })); - } catch { - // Role may already be deleted or in use -- best effort - } -} diff --git a/src/cli/operations/deploy/post-deploy-knowledge-bases.ts b/src/cli/operations/deploy/post-deploy-knowledge-bases.ts new file mode 100644 index 000000000..1f2cce667 --- /dev/null +++ b/src/cli/operations/deploy/post-deploy-knowledge-bases.ts @@ -0,0 +1,134 @@ +import type { DeployedState, KnowledgeBase, KnowledgeBaseDeployedState } from '../../../schema'; +import { runKbIngestionByName } from '../ingest'; +import { createHash } from 'node:crypto'; + +export interface AutoIngestKnowledgeBasesOptions { + region: string; + knowledgeBases: KnowledgeBase[]; + /** Current deployed-state record (KB id/arn populated, dataSources hydrated, sourcesHash possibly stale). */ + deployedKnowledgeBases: Record; + /** Prior deployed-state record for sourcesHash comparison. */ + previousKnowledgeBases?: Record; + /** Deployment target name (passed through to runKbIngestionByName). */ + targetName: string; + /** Full deployed-state (passed through to runKbIngestionByName). */ + deployedState: DeployedState; + /** + * Optional progress callback. When the retry loop sleeps because Bedrock + * is busy with a sibling job, this is called with a short status line so + * the deploy logger can echo it (otherwise the deploy looks frozen). + */ + onProgress?: (message: string) => void; + /** Optional abort signal forwarded to the retry sleep. */ + signal?: AbortSignal; +} + +export interface AutoIngestEntry { + knowledgeBaseName: string; + status: 'started' | 'skipped' | 'error'; + /** Number of data sources for which an ingestion job was started. */ + startedJobCount?: number; + /** Reason for skipping (e.g. 'no changes to data sources'). */ + reason?: string; + /** Error message when status is 'error'. */ + error?: string; + /** New sourcesHash to persist when status is 'started'. */ + newSourcesHash?: string; +} + +export interface AutoIngestKnowledgeBasesResult { + hasErrors: boolean; + results: AutoIngestEntry[]; +} + +/** + * Compute the SHA-256 over the data-source URIs of a KB spec, joined with + * newlines. The post-deploy hook compares this to the previously-stored + * sourcesHash to decide whether to re-trigger ingestion. + */ +export function computeSourcesHash(kb: KnowledgeBase): string { + return createHash('sha256') + .update(kb.dataSources.map(ds => (ds.type === 'S3' ? ds.uri : ds.connectorConfigFile)).join('\n')) + .digest('hex'); +} + +/** + * For each KB in the project, fire StartIngestionJob if the current + * sourcesHash differs from the one stored in the prior deployed-state. + * + * Skipped KBs (no change) are reported in `results` with status 'skipped'. + * Errors are reported with status 'error' and surfaced in `hasErrors`; they + * do NOT abort the post-deploy flow because ingestion is async and retryable + * via `agentcore run ingest`. + * + * Caller is responsible for persisting `newSourcesHash` onto the deployed + * state record after this returns. + */ +export async function autoIngestKnowledgeBases( + opts: AutoIngestKnowledgeBasesOptions +): Promise { + const results: AutoIngestEntry[] = []; + + for (const kb of opts.knowledgeBases) { + const deployed = opts.deployedKnowledgeBases[kb.name]; + if (!deployed) { + // KB wasn't deployed (CFN output missing) — nothing to ingest into yet. + results.push({ + knowledgeBaseName: kb.name, + status: 'skipped', + reason: 'KB not present in deployed state (CFN outputs missing)', + }); + continue; + } + if (deployed.dataSources.length === 0) { + results.push({ + knowledgeBaseName: kb.name, + status: 'skipped', + reason: 'no data sources recorded', + }); + continue; + } + + const newHash = computeSourcesHash(kb); + const previousHash = opts.previousKnowledgeBases?.[kb.name]?.sourcesHash; + + if (previousHash && previousHash === newHash) { + results.push({ + knowledgeBaseName: kb.name, + status: 'skipped', + reason: 'no changes to data sources', + }); + continue; + } + + const ingestResult = await runKbIngestionByName({ + knowledgeBaseName: kb.name, + deployedState: opts.deployedState, + targetName: opts.targetName, + region: opts.region, + onProgress: opts.onProgress, + signal: opts.signal, + }); + + if (!ingestResult.success) { + results.push({ + knowledgeBaseName: kb.name, + status: 'error', + error: ingestResult.error.message, + }); + continue; + } + + results.push({ + knowledgeBaseName: kb.name, + status: 'started', + startedJobCount: ingestResult.startedJobs.length, + newSourcesHash: newHash, + }); + } + + return { + hasErrors: results.some(r => r.status === 'error'), + results, + }; +} diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 044fa1e56..407384075 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -16,6 +16,8 @@ export interface PreflightContext { cdkProject: LocalCdkProject; /** True when agents array is empty but a deployed stack exists — deploy will tear down resources */ isTeardownDeploy: boolean; + /** True when deployed-state.json has no targets — stack has never been deployed */ + isFirstDeploy: boolean; } export interface SynthResult { @@ -60,7 +62,6 @@ export function formatError(err: unknown): string { * Returns the project context needed for subsequent steps. */ const MAX_RUNTIME_NAME_LENGTH = 48; -const MAX_GATEWAY_COMBINED_NAME_LENGTH = 48; export async function validateProject(): Promise { // Find the agentcore config directory, walking up from cwd if needed @@ -78,13 +79,22 @@ export async function validateProject(): Promise { // Validate that at least one agent or gateway is defined, unless this is a teardown deploy. // - // Teardown detection: when agents is empty but deployed-state.json records existing - // targets, the user has run `remove all` and wants to tear down AWS resources via deploy. // deployed-state.json is written by the CLI after every successful deploy, so it is a // reliable indicator of whether a CloudFormation stack exists for this project. + let hasExistingStack = false; + try { + const deployedState = await configIO.readDeployedState(); + hasExistingStack = Object.keys(deployedState.targets).length > 0; + } catch { + // No deployed state file — no existing stack + } + + // Teardown detection: when agents is empty but deployed-state.json records existing + // targets, the user has run `remove all` and wants to tear down AWS resources via deploy. let isTeardownDeploy = false; const hasAgents = projectSpec.runtimes && projectSpec.runtimes.length > 0; const hasMemories = projectSpec.memories && projectSpec.memories.length > 0; + const hasKnowledgeBases = projectSpec.knowledgeBases && projectSpec.knowledgeBases.length > 0; const hasEvaluators = projectSpec.evaluators && projectSpec.evaluators.length > 0; const hasPolicyEngines = projectSpec.policyEngines && projectSpec.policyEngines.length > 0; const hasHarnesses = projectSpec.harnesses && projectSpec.harnesses.length > 0; @@ -98,22 +108,16 @@ export async function validateProject(): Promise { !hasAgents && !hasGateways && !hasMemories && + !hasKnowledgeBases && !hasEvaluators && !hasPolicyEngines && !hasHarnesses && !hasDatasets && !hasPayments ) { - let hasExistingStack = false; - try { - const deployedState = await configIO.readDeployedState(); - hasExistingStack = Object.keys(deployedState.targets).length > 0; - } catch { - // No deployed state file — no existing stack - } if (!hasExistingStack) { throw new ValidationError( - 'No resources defined in project. Add at least one resource (agent, memory, evaluator, or gateway) before deploying.' + 'No resources defined in project. Add at least one resource (agent, memory, knowledge base, evaluator, or gateway) before deploying.' ); } isTeardownDeploy = true; @@ -122,19 +126,24 @@ export async function validateProject(): Promise { // Validate runtime names don't exceed AWS limits validateRuntimeNames(projectSpec); - // Validate HTTP gateway names don't exceed AWS limits when combined with project name - validateHttpGatewayNames(projectSpec); - // Validate Container agents have Dockerfiles validateContainerAgents(projectSpec, configRoot); + // Validate per-harness harness.json up front so a schema error shows its precise message + // here instead of as an opaque "CDK synth failed" during synth. Skipped on a teardown deploy: + // tearing down a project with a hand-broken harness.json must not be blocked by validating the + // very files the user is discarding (mirrors the credential-skip rationale below). + if (!isTeardownDeploy) { + await validateHarnessSpecs(projectSpec, configRoot); + } + // Validate AWS credentials before proceeding with build/synth. // Skip for teardown deploys — callers validate after teardown confirmation. if (!isTeardownDeploy) { await validateAwsCredentials(); } - return { projectSpec, awsTargets, cdkProject, isTeardownDeploy }; + return { projectSpec, awsTargets, cdkProject, isTeardownDeploy, isFirstDeploy: !hasExistingStack }; } /** @@ -157,36 +166,6 @@ function validateRuntimeNames(projectSpec: AgentCoreProjectSpec): void { } } -/** - * Validates that combined HTTP gateway names (projectName-gatewayName) don't exceed AWS limits. - */ -function validateHttpGatewayNames(projectSpec: AgentCoreProjectSpec): void { - const projectName = projectSpec.name; - for (const gateway of projectSpec.httpGateways ?? []) { - const gwName = gateway.name; - if (gwName) { - const combinedName = `${projectName}-${gwName}`; - if (combinedName.length > MAX_GATEWAY_COMBINED_NAME_LENGTH) { - throw new Error( - `HTTP gateway name too long: "${combinedName}" (${combinedName.length} chars). ` + - `AWS limits gateway names to ${MAX_GATEWAY_COMBINED_NAME_LENGTH} characters. ` + - `Shorten the project name or gateway name in agentcore.json.` - ); - } - } - for (const target of gateway.targets ?? []) { - const combined = `${projectName}-${target.name}`; - if (combined.length > MAX_GATEWAY_COMBINED_NAME_LENGTH) { - const maxTargetLen = MAX_GATEWAY_COMBINED_NAME_LENGTH - projectName.length - 1; - throw new Error( - `HTTP gateway target "${target.name}" in gateway "${gwName}" would exceed the ${MAX_GATEWAY_COMBINED_NAME_LENGTH}-character AWS limit when prefixed with project name "${projectName}-" (total: ${combined.length} chars). ` + - `Shorten the target name to ${maxTargetLen} characters or fewer.` - ); - } - } - } -} - /** * Validates that Container agents have required Dockerfiles. */ @@ -211,6 +190,30 @@ export function validateContainerAgents(projectSpec: AgentCoreProjectSpec, confi } } +/** + * Validate every per-harness `harness.json` against HarnessSpecSchema so a bad harness spec + * fails preflight with the precise Zod message (e.g. "sessionStoragePath ... pattern") instead + * of surfacing only "CDK synth failed: Subprocess exited with error 1" mid-deploy. The vended + * CDK app re-parses these at synth, so this just moves the same error earlier and makes it readable. + */ +export async function validateHarnessSpecs(projectSpec: AgentCoreProjectSpec, configRoot: string): Promise { + const harnesses = projectSpec.harnesses ?? []; + if (harnesses.length === 0) return; + + const configIO = new ConfigIO({ baseDir: configRoot }); + const errors: string[] = []; + for (const harness of harnesses) { + try { + await configIO.readHarnessSpec(harness.name); + } catch (err) { + errors.push(`Harness "${harness.name}": ${formatError(err)}`); + } + } + if (errors.length > 0) { + throw new ValidationError(`Invalid harness configuration:\n${errors.join('\n')}`); + } +} + const DEPRECATED_BASE_IMAGES: Record = { 'slim-bookworm': 'Affected by CVE-2026-42010 (GnuTLS authentication bypass). Update the FROM line to use a Trixie-based variant.', diff --git a/src/cli/operations/deploy/teardown.ts b/src/cli/operations/deploy/teardown.ts index 73b4777a6..d8e5541b7 100644 --- a/src/cli/operations/deploy/teardown.ts +++ b/src/cli/operations/deploy/teardown.ts @@ -3,10 +3,10 @@ import type { Result } from '../../../lib/result'; import type { AwsDeploymentTarget } from '../../../schema'; import { withTargetRegion } from '../../aws'; import { deleteConfigurationBundle } from '../../aws/agentcore-config-bundles'; +import { deleteHarness, isHarnessNotFoundError } from '../../aws/agentcore-harness'; import { CdkToolkitWrapper, silentIoHost } from '../../cdk/toolkit-lib'; import { type DiscoveredStack, findStack } from '../../cloudformation/stack-discovery'; -import { deleteOrphanedABTests } from './post-deploy-ab-tests'; -import { deleteOrphanedHttpGateways } from './post-deploy-http-gateways'; +import { findOrphanHarnesses } from '../harness/orphan'; import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; import { existsSync } from 'fs'; import { join } from 'path'; @@ -120,7 +120,29 @@ export async function performStackTeardown(targetName: string): Promise const deployedState = await configIO.readDeployedState(); const resources = deployedState.targets?.[targetName]?.resources; - if (resources?.httpGateways || resources?.configBundles || resources?.abTests) { + // Delete imperative-build orphan harnesses. CloudFormation never created them so the stack + // destroy below won't touch them, and teardown removes the deployed-state they're recorded in — + // so a post-teardown `remove harness --discard` could no longer find them, leaving them running + // and billing forever. Teardown means "remove everything", so delete them here (using the + // recorded id+region, never re-resolving by name). 404 = already gone (success); other errors + // warn but don't abort the teardown. + for (const orphan of findOrphanHarnesses(deployedState, undefined).filter(o => o.targetName === targetName)) { + try { + await deleteHarness({ region: orphan.region, harnessId: orphan.harnessId }); + console.log(`Deleted preview-build harness "${orphan.name}"`); + } catch (err) { + if (isHarnessNotFoundError(err)) { + // Already gone — nothing to do. + } else { + console.warn( + `Warning: Could not delete preview-build harness "${orphan.name}" (${orphan.harnessId}) in ` + + `${orphan.region}: ${err instanceof Error ? err.message : String(err)}. Delete it manually to stop incurring cost.` + ); + } + } + } + + if (resources?.configBundles) { let region = deployedTarget?.target.region; if (!region) { try { @@ -135,39 +157,6 @@ export async function performStackTeardown(targetName: string): Promise console.warn('Warning: Could not determine region for resource cleanup — resources may need manual deletion'); } if (region) { - const projectSpec = await configIO.readProjectSpec(); - const emptySpec = { ...projectSpec, abTests: [], httpGateways: [] }; - - if (resources.abTests) { - const abResult = await deleteOrphanedABTests({ - region, - projectSpec: emptySpec, - existingABTests: resources.abTests, - }); - for (const r of abResult.results) { - if (r.status === 'deleted') { - console.log(`Deleted AB test "${r.testName}"`); - } else if (r.error) { - console.warn(`Warning: Failed to delete AB test "${r.testName}": ${r.error}`); - } - } - } - - if (resources.httpGateways) { - const gwResult = await deleteOrphanedHttpGateways({ - region, - projectSpec: emptySpec, - existingHttpGateways: resources.httpGateways, - }); - for (const r of gwResult.results) { - if (r.status === 'deleted') { - console.log(`Deleted HTTP gateway "${r.gatewayName}"`); - } else if (r.error) { - console.warn(`Warning: Failed to delete HTTP gateway "${r.gatewayName}": ${r.error}`); - } - } - } - for (const [bundleName, bundleState] of Object.entries(resources.configBundles ?? {})) { try { await deleteConfigurationBundle({ region, bundleId: bundleState.bundleId }); diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index a0a86b665..a8e2289b3 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -16,6 +16,7 @@ describe('getDevConfig', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -23,7 +24,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -50,6 +50,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -57,7 +58,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -83,6 +83,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -90,7 +91,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -122,6 +122,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -129,7 +130,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -156,6 +156,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -163,7 +164,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -191,6 +191,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -198,7 +199,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -226,6 +226,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -233,7 +234,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -261,6 +261,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -268,7 +269,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -296,6 +296,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -303,7 +304,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -330,6 +330,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -337,7 +338,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -364,6 +364,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -371,7 +372,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -398,6 +398,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -405,7 +406,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -432,6 +432,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -439,7 +440,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -467,6 +467,7 @@ describe('getDevConfig', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -474,7 +475,6 @@ describe('getDevConfig', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -515,6 +515,7 @@ describe('getAgentPort', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -522,7 +523,6 @@ describe('getAgentPort', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -539,6 +539,7 @@ describe('getAgentPort', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -546,7 +547,6 @@ describe('getAgentPort', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -568,6 +568,7 @@ describe('getDevSupportedAgents', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -575,7 +576,6 @@ describe('getDevSupportedAgents', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -600,6 +600,7 @@ describe('getDevSupportedAgents', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -607,7 +608,6 @@ describe('getDevSupportedAgents', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -642,6 +642,7 @@ describe('getDevSupportedAgents', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -649,7 +650,6 @@ describe('getDevSupportedAgents', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], payments: [], }; @@ -674,6 +674,7 @@ describe('getDevSupportedAgents', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -681,7 +682,6 @@ describe('getDevSupportedAgents', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], @@ -716,6 +716,7 @@ describe('getDevSupportedAgents', () => { }, ], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -723,7 +724,6 @@ describe('getDevSupportedAgents', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], diff --git a/src/cli/operations/dev/__tests__/sse-transform.test.ts b/src/cli/operations/dev/__tests__/sse-transform.test.ts new file mode 100644 index 000000000..7dda61820 --- /dev/null +++ b/src/cli/operations/dev/__tests__/sse-transform.test.ts @@ -0,0 +1,111 @@ +import { pipeSSETransformed } from '../sse-transform'; +import { type IncomingMessage, type ServerResponse } from 'node:http'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; + +function createMockInput(): PassThrough & { headers: Record } { + const stream = new PassThrough() as PassThrough & { headers: Record }; + stream.headers = {}; + return stream; +} + +function createMockOutput(): { chunks: string[]; ended: boolean; write: (data: string) => boolean; end: () => void } { + const mock = { + chunks: [] as string[], + ended: false, + write: (data: string) => { + mock.chunks.push(data); + return true; + }, + end: () => { + mock.ended = true; + }, + }; + return mock; +} + +describe('pipeSSETransformed', () => { + it('transforms ConverseStream events to plain text SSE', async () => { + const input = createMockInput(); + const output = createMockOutput(); + + const done = pipeSSETransformed(input as unknown as IncomingMessage, output as unknown as ServerResponse); + + input.write('data: {"event":{"contentBlockDelta":{"delta":{"text":"Hello"}}}}\n\n'); + input.write('data: {"event":{"contentBlockDelta":{"delta":{"text":" world"}}}}\n\n'); + input.end(); + + await done; + + expect(output.chunks).toEqual(['data: "Hello"\n\n', 'data: " world"\n\n']); + expect(output.ended).toBe(true); + }); + + it('passes through already-parsed string events', async () => { + const input = createMockInput(); + const output = createMockOutput(); + + const done = pipeSSETransformed(input as unknown as IncomingMessage, output as unknown as ServerResponse); + + input.write('data: "simple text"\n\n'); + input.end(); + + await done; + + expect(output.chunks).toEqual(['data: "simple text"\n\n']); + }); + + it('forwards errors as JSON objects', async () => { + const input = createMockInput(); + const output = createMockOutput(); + + const done = pipeSSETransformed(input as unknown as IncomingMessage, output as unknown as ServerResponse); + + input.write('data: {"error":"something broke"}\n\n'); + input.end(); + + await done; + + expect(output.chunks).toEqual(['data: {"error":"something broke"}\n\n']); + }); + + it('handles chunked data split across boundaries', async () => { + const input = createMockInput(); + const output = createMockOutput(); + + const done = pipeSSETransformed(input as unknown as IncomingMessage, output as unknown as ServerResponse); + + input.write('data: {"event":{"contentBlock'); + input.write('Delta":{"delta":{"text":"split"}}}}\n\n'); + input.end(); + + await done; + + expect(output.chunks).toEqual(['data: "split"\n\n']); + }); + + it('handles {"text": "..."} format from bedrock runtime', async () => { + const input = createMockInput(); + const output = createMockOutput(); + + const done = pipeSSETransformed(input as unknown as IncomingMessage, output as unknown as ServerResponse); + + input.write('data: {"text":"bedrock response"}\n\n'); + input.end(); + + await done; + + expect(output.chunks).toEqual(['data: "bedrock response"\n\n']); + }); + + it('rejects on input error', async () => { + const input = createMockInput(); + const output = createMockOutput(); + + const done = pipeSSETransformed(input as unknown as IncomingMessage, output as unknown as ServerResponse); + + input.destroy(new Error('connection reset')); + + await expect(done).rejects.toThrow('connection reset'); + }); +}); diff --git a/src/cli/operations/dev/invoke.ts b/src/cli/operations/dev/invoke.ts index 9a385e574..a91378ab3 100644 --- a/src/cli/operations/dev/invoke.ts +++ b/src/cli/operations/dev/invoke.ts @@ -9,13 +9,13 @@ export { type InvokeStreamingOptions, type SSELogger } from './invoke-types'; /** * Parse a single SSE data line and extract the content. */ -function parseSSELine(line: string): { content: string | null; error: string | null } { +export function parseSSELine(line: string): { content: string | null; error: string | null } { if (!line.startsWith('data: ')) { return { content: null, error: null }; } - const content = line.slice(6); + const raw = line.slice(6); try { - const parsed: unknown = JSON.parse(content); + const parsed: unknown = JSON.parse(raw); if (typeof parsed === 'string') { return { content: parsed, error: null }; } else if (parsed && typeof parsed === 'object') { @@ -27,8 +27,14 @@ function parseSSELine(line: string): { content: string | null; error: string | n return { content: String((parsed as { text: unknown }).text), error: null }; } } + // ConverseStream-shaped event: extract text delta + const event = (parsed as { event?: { contentBlockDelta?: { delta?: { text?: string } } } })?.event; + const text = event?.contentBlockDelta?.delta?.text; + if (typeof text === 'string') { + return { content: text, error: null }; + } } catch { - return { content, error: null }; + return { content: raw, error: null }; } return { content: null, error: null }; } diff --git a/src/cli/operations/dev/sse-transform.ts b/src/cli/operations/dev/sse-transform.ts new file mode 100644 index 000000000..74bcb80be --- /dev/null +++ b/src/cli/operations/dev/sse-transform.ts @@ -0,0 +1,45 @@ +import { parseSSELine } from './invoke'; +import { type IncomingMessage, type ServerResponse } from 'node:http'; + +/** + * Pipe an SSE stream from an agent response to a client response, + * transforming each SSE event through parseSSELine so formats like + * ConverseStream are normalized to plain text before reaching the browser. + * + * Non-text content (errors) is forwarded as `data: {"error": "..."}\n\n`. + * Parsed text is forwarded as `data: "text"\n\n`. + */ +export function pipeSSETransformed(input: IncomingMessage, output: ServerResponse): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + + input.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + const { content, error } = parseSSELine(line); + if (error) { + output.write(`data: ${JSON.stringify({ error })}\n\n`); + } else if (content) { + output.write(`data: ${JSON.stringify(content)}\n\n`); + } + } + }); + + input.on('end', () => { + if (buffer) { + const { content, error } = parseSSELine(buffer); + if (error) { + output.write(`data: ${JSON.stringify({ error })}\n\n`); + } else if (content) { + output.write(`data: ${JSON.stringify(content)}\n\n`); + } + } + output.end(); + resolve(); + }); + + input.on('error', reject); + }); +} diff --git a/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts b/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts index 6308a1f24..200f59f78 100644 --- a/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts +++ b/src/cli/operations/dev/web-ui/__tests__/resolve-ui-dist-dir.test.ts @@ -1,6 +1,5 @@ import { resolveUIDistDir } from '../web-server.js'; -import fs from 'node:fs'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import fs, { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/src/cli/operations/dev/web-ui/api-types.ts b/src/cli/operations/dev/web-ui/api-types.ts index 17090244e..b5ea2be12 100644 --- a/src/cli/operations/dev/web-ui/api-types.ts +++ b/src/cli/operations/dev/web-ui/api-types.ts @@ -9,7 +9,7 @@ * from a single source of truth instead of manually duplicating. */ import type { HarnessModel } from '../../../../schema'; -import type { HarnessModelConfiguration, HarnessTool } from '../../../aws/agentcore-harness'; +import type { HarnessModelConfiguration, HarnessSkill, HarnessTool } from '../../../aws/agentcore-harness'; import type { CloudWatchSpanRecord, CloudWatchTraceRecord } from '../../traces/types'; // --------------------------------------------------------------------------- @@ -207,10 +207,13 @@ export interface ResourceEvaluator { /** Online eval config details in the resources response */ export interface ResourceOnlineEvalConfig { name: string; - agent: string; - evaluators: string[]; + agent?: string; + evaluators?: string[]; + insights?: string[]; samplingRate: number; description?: string; + logGroupNames?: string[]; + serviceNames?: string[]; deploymentStatus?: ResourceDeploymentStatus; deployed?: DeployedOnlineEvalState; } @@ -442,7 +445,7 @@ export interface A2AAgentCardResponse { export interface HarnessInvocationOverrides { model?: HarnessModelConfiguration; systemPrompt?: string; - skills?: { path: string }[]; + skills?: HarnessSkill[]; actorId?: string; maxIterations?: number; maxTokens?: number; @@ -462,12 +465,21 @@ export interface StatusHarness { name: string; } +export type ResourceSkillSource = + | { path: string } + | { s3: { uri: string } } + | { + git: { url: string; path?: string; auth?: { credentialName: string; credentialArn?: string; username?: string } }; + } + | { awsSkills: { paths?: string[] } }; + export interface ResourceHarness { name: string; /** @deprecated Use modelConfig instead. */ model: string; modelConfig?: HarnessModel; tools: string[]; + skills?: ResourceSkillSource[]; deploymentStatus?: ResourceDeploymentStatus; deployed?: DeployedHarnessState; } diff --git a/src/cli/operations/dev/web-ui/handlers/invocations.ts b/src/cli/operations/dev/web-ui/handlers/invocations.ts index 45148874b..4b8fe09b5 100644 --- a/src/cli/operations/dev/web-ui/handlers/invocations.ts +++ b/src/cli/operations/dev/web-ui/handlers/invocations.ts @@ -3,6 +3,7 @@ import { invokeA2ARuntime, invokeAgentRuntimeStreaming, invokeAguiRuntime } from import { buildAguiRunInput } from '../../../../aws/agui-types'; import { resolveInvokeTarget } from '../../../../commands/invoke/resolve'; import { extractSSEEventText, extractTaskText, isStatusUpdateEvent } from '../../invoke-a2a'; +import { pipeSSETransformed } from '../../sse-transform'; import { handleHarnessInvocation } from './harness-invocation'; import { type RouteContext, parseRequestUrl } from './route-context'; import { randomUUID } from 'node:crypto'; @@ -112,9 +113,14 @@ export async function handleInvocations( responseHeaders['x-session-id'] = sessionId; } res.writeHead(agentRes.statusCode ?? 200, responseHeaders); - agentRes.pipe(res); - agentRes.on('end', resolve); - agentRes.on('error', reject); + + if (contentType.includes('text/event-stream')) { + pipeSSETransformed(agentRes, res).then(resolve, reject); + } else { + agentRes.pipe(res); + agentRes.on('end', resolve); + agentRes.on('error', reject); + } } ); diff --git a/src/cli/operations/dev/web-ui/handlers/resources.ts b/src/cli/operations/dev/web-ui/handlers/resources.ts index 25771cc11..8f48a9691 100644 --- a/src/cli/operations/dev/web-ui/handlers/resources.ts +++ b/src/cli/operations/dev/web-ui/handlers/resources.ts @@ -13,6 +13,7 @@ import type { ResourceMemory, ResourceOnlineEvalConfig, ResourcePolicyEngine, + ResourceSkillSource, } from '../api-types'; import type { RouteContext } from './route-context'; import type { ServerResponse } from 'node:http'; @@ -121,11 +122,36 @@ export async function handleResources(ctx: RouteContext, res: ServerResponse, or let model = ''; let modelConfig: ResourceHarness['modelConfig']; let tools: string[] = []; + let skills: ResourceSkillSource[] | undefined; try { const spec = await configIO.readHarnessSpec(h.name); model = `${spec.model.provider}/${spec.model.modelId}`; modelConfig = spec.model; tools = spec.tools.map(t => t.name); + if (spec.skills.length > 0) { + skills = spec.skills.map(s => { + if ('s3Uri' in s) return { s3: { uri: s.s3Uri } }; + if ('gitUrl' in s) { + return { + git: { + url: s.gitUrl, + ...(s.path && { path: s.path }), + ...(s.auth && { + auth: { + credentialName: s.auth.credentialName, + credentialArn: targetResources?.credentials?.[s.auth.credentialName]?.credentialProviderArn, + username: s.auth.username, + }, + }), + }, + }; + } + if ('awsSkills' in s) { + return { awsSkills: { ...(s.awsSkills.paths && { paths: s.awsSkills.paths }) } }; + } + return { path: s.path }; + }); + } } catch { // harness spec may be unreadable — show what we can } @@ -135,6 +161,7 @@ export async function handleResources(ctx: RouteContext, res: ServerResponse, or model, modelConfig, tools, + skills, deploymentStatus: statusByTypeAndName.get(`harness:${h.name}`), deployed: deployed ? { harnessId: deployed.harnessId, harnessArn: deployed.harnessArn } : undefined, }); diff --git a/src/cli/operations/eval/batch-eval-storage.ts b/src/cli/operations/eval/batch-eval-storage.ts deleted file mode 100644 index 2c47141d1..000000000 --- a/src/cli/operations/eval/batch-eval-storage.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { findConfigRoot } from '../../../lib'; -import type { EvaluationResults } from '../../aws/agentcore-batch-evaluation'; -import type { BatchEvaluationResult, RunBatchEvaluationCommandResult } from './run-batch-evaluation'; -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'; -import { join } from 'path'; - -export const BATCH_EVAL_RESULTS_DIR = 'batch-eval-results'; - -export interface BatchEvalRunRecord { - name: string; - batchEvaluationId: string; - status: string; - startedAt?: string; - completedAt?: string; - evaluators: string[]; - results: BatchEvaluationResult[]; - evaluationResults?: EvaluationResults; - source?: string; - dataset?: { id: string; version: string }; -} - -function getResultsDir(): string { - const configRoot = findConfigRoot(); - if (!configRoot) { - throw new Error('No agentcore project found. Run `agentcore create` first.'); - } - return join(configRoot, '.cli', BATCH_EVAL_RESULTS_DIR); -} - -export interface SaveBatchEvalRunOptions { - result: RunBatchEvaluationCommandResult; - source?: string; - dataset?: { id: string; version: string }; -} - -export function saveBatchEvalRun(resultOrOptions: RunBatchEvaluationCommandResult | SaveBatchEvalRunOptions): string { - const dir = getResultsDir(); - mkdirSync(dir, { recursive: true }); - - // Support both the legacy signature and the new options object - const isOptionsObj = 'result' in resultOrOptions; - const result = isOptionsObj ? resultOrOptions.result : resultOrOptions; - const source = isOptionsObj ? resultOrOptions.source : undefined; - const dataset = isOptionsObj ? resultOrOptions.dataset : undefined; - - const id = result.batchEvaluationId ?? 'unknown'; - const filePath = join(dir, `${id}.json`); - - const record: BatchEvalRunRecord = { - name: result.name ?? 'unknown', - batchEvaluationId: id, - status: result.status ?? 'unknown', - startedAt: result.startedAt, - completedAt: result.completedAt, - evaluators: result.results.map(r => r.evaluatorId), - results: result.results, - evaluationResults: result.evaluationResults, - ...(source ? { source } : {}), - ...(dataset ? { dataset } : {}), - }; - - writeFileSync(filePath, JSON.stringify(record, null, 2)); - return filePath; -} - -export function loadBatchEvalRun(batchEvaluationId: string): BatchEvalRunRecord { - const dir = getResultsDir(); - const jsonName = batchEvaluationId.endsWith('.json') ? batchEvaluationId : `${batchEvaluationId}.json`; - const filePath = join(dir, jsonName); - - if (!existsSync(filePath)) { - throw new Error(`Batch evaluation run "${batchEvaluationId}" not found at ${filePath}`); - } - - return JSON.parse(readFileSync(filePath, 'utf-8')) as BatchEvalRunRecord; -} - -export function listBatchEvalRuns(): BatchEvalRunRecord[] { - const dir = getResultsDir(); - - if (!existsSync(dir)) { - return []; - } - - const files = readdirSync(dir) - .filter(f => f.endsWith('.json')) - .sort() - .reverse(); - - return files.map(f => JSON.parse(readFileSync(join(dir, f), 'utf-8')) as BatchEvalRunRecord); -} diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts deleted file mode 100644 index 4b68456ed..000000000 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ /dev/null @@ -1,428 +0,0 @@ -/** - * Orchestrates running a BatchEvaluation: - * 1. Resolve agent from deployed state (for serviceNames / logGroupNames) - * 2. Build evaluators + dataSourceConfig - * 3. Call StartBatchEvaluation - * 4. Poll GetBatchEvaluation until terminal status - * 5. Return results - */ -import { ConfigIO, ResourceNotFoundError, ValidationError, toError } from '../../../lib'; -import type { Result } from '../../../lib/result'; -import type { DeployedState } from '../../../schema'; -import { generateClientToken, getBatchEvaluation, startBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; -import type { - CloudWatchFilterConfig, - EvaluationResults, - GetBatchEvaluationResult, - SessionMetadataEntry, -} from '../../aws/agentcore-batch-evaluation'; -import { resolveEndpointName, runtimeLogGroup } from '../../aws/cloudwatch'; -import { getRegion } from '../../commands/shared/region-utils'; -import { ExecLogger } from '../../logging/exec-logger'; -import { resolveAgentContext } from '../invoke/resolve-agent-context'; -import { runDatasetScenarios } from './shared/dataset-session-provider'; -import { CloudWatchLogsClient, GetLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface RunBatchEvaluationOptions { - /** Agent name (from project config) */ - agent: string; - /** Evaluator IDs (Builtin.* or custom) */ - evaluators: string[]; - /** Optional name for the batch evaluation */ - name?: string; - /** Region override */ - region?: string; - /** Specific session IDs to evaluate (optional — filters CloudWatch source) */ - sessionIds?: string[]; - /** Lookback window in days (optional — filters CloudWatch source by time range) */ - lookbackDays?: number; - /** Session metadata with ground truth (assertions, expected trajectory, turns) */ - sessionMetadata?: SessionMetadataEntry[]; - /** Poll interval in ms */ - pollIntervalMs?: number; - /** Progress callback */ - onProgress?: (status: string, message: string) => void; - /** Called once the batch evaluation has been created, with ID and region for cancellation */ - onStarted?: (info: { batchEvaluationId: string; region: string }) => void; - /** Dataset name — invoke agent with dataset scenarios before batch evaluation */ - dataset?: string; - /** Dataset version (omit for local file, or N/DRAFT) */ - datasetVersion?: string; - /** Runtime endpoint name (e.g. PROMPT_V1). Defaults to DEFAULT. */ - endpoint?: string; -} - -export interface BatchEvaluationResult { - evaluatorId: string; - score?: number; - label?: string; - explanation?: string; - error?: string; -} - -export type RunBatchEvaluationCommandResult = Result & { - batchEvaluationId?: string; - name?: string; - status?: string; - results: BatchEvaluationResult[]; - evaluationResults?: EvaluationResults; - startedAt?: string; - completedAt?: string; - logFilePath?: string; -}; - -// ============================================================================ -// Constants -// ============================================================================ - -const DEFAULT_POLL_INTERVAL_MS = 10_000; - -/** Delay before submitting batch eval to allow CloudWatch span ingestion. Matches SDK default. */ -const BATCH_INGESTION_DELAY_MS = 180_000; -const TERMINAL_STATUSES = new Set(['COMPLETED', 'COMPLETED_WITH_ERRORS', 'FAILED', 'STOPPED', 'CANCELLED']); - -// ============================================================================ -// Implementation -// ============================================================================ - -export async function runBatchEvaluationCommand( - options: RunBatchEvaluationOptions -): Promise { - const { agent, evaluators, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, onProgress } = options; - - let logger: ExecLogger | undefined; - try { - logger = new ExecLogger({ command: 'batch-evaluate' }); - } catch { - // Non-fatal - } - - try { - // 1. Read project config and deployed state - logger?.startStep('Load project config'); - const configIO = new ConfigIO(); - const [projectSpec, deployedState, awsTargets] = await Promise.all([ - configIO.readProjectSpec(), - configIO.readDeployedState(), - configIO.resolveAWSDeploymentTargets(), - ]); - - const region = await getRegion(options.region); - const stage = process.env.AGENTCORE_STAGE?.toLowerCase() ?? 'prod'; - logger?.log(`Region: ${region}, Stage: ${stage}`); - logger?.endStep('success'); - - // 2. Resolve agent from deployed state - logger?.startStep('Resolve agent'); - const agentState = resolveAgentState(deployedState, agent); - if (!agentState) { - const error = `Agent "${agent}" not deployed. Run \`agentcore deploy\` first.`; - logger?.log(error, 'error'); - logger?.endStep('error', error); - logger?.finalize(false); - return { success: false, error: new ResourceNotFoundError(error), results: [], logFilePath: logger?.logFilePath }; - } - - const runtimeId = agentState.runtimeId; - // Service name in CW logs uses project_agent format without the CDK hash suffix - const endpointName = resolveEndpointName(options.endpoint); - const serviceName = `${projectSpec.name}_${agent}.${endpointName}`; - const runtimeLogGroupName = runtimeLogGroup(runtimeId, options.endpoint); - - logger?.log(`Agent: ${agent} (runtime: ${runtimeId})`); - logger?.log(`Service name: ${serviceName}`); - logger?.log(`Log group: ${runtimeLogGroupName}`); - logger?.endStep('success'); - - // 2b. Resolve evaluator names to deployed IDs - // Handles: "Builtin.Correctness", "arn:aws:...:evaluator/Builtin.Correctness", or custom evaluator names - const targetResources = Object.values(deployedState.targets).find(t => t.resources?.runtimes?.[agent])?.resources; - const resolvedEvaluators = evaluators.map(name => { - // Extract short name from ARN if passed (e.g. "arn:aws:bedrock-agentcore:::evaluator/Builtin.Correctness" → "Builtin.Correctness") - const shortName = name.includes('evaluator/') ? name.split('evaluator/').pop()! : name; - if (shortName.startsWith('Builtin.')) return shortName; - const deployed = targetResources?.evaluators?.[shortName]; - if (deployed?.evaluatorId) { - logger?.log(`Resolved evaluator "${shortName}" → ${deployed.evaluatorId}`); - return deployed.evaluatorId; - } - logger?.log(`Evaluator "${shortName}" not found in deployed state, passing as-is`, 'warn'); - return shortName; - }); - - // 3. Start the batch evaluation - logger?.startStep('Start batch evaluation'); - let evalName: string; - if (options.name) { - if (!/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/.test(options.name)) { - return { - success: false, - error: new ValidationError( - `Batch evaluation name must start with a letter and contain only letters, digits, and underscores (max 48 chars). Got: "${options.name}"` - ), - results: [], - logFilePath: logger?.logFilePath, - }; - } - evalName = options.name; - } else { - evalName = `${projectSpec.name}_${agent}_${Date.now()}`.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 48); - } - - onProgress?.('starting', `Starting batch evaluation "${evalName}"...`); - - // Dataset mode: invoke agent with scenarios first, then use those sessionIds - let datasetSessionIds: string[] = []; - let datasetMetadata: SessionMetadataEntry[] = []; - if (options.dataset) { - const agentContext = await resolveAgentContext({ - project: projectSpec, - deployedState, - awsTargets, - agentName: agent, - endpoint: options.endpoint, - }); - - onProgress?.('invoking', `Invoking agent with dataset "${options.dataset}"...`); - - const datasetResult = await runDatasetScenarios({ - agentContext, - datasetName: options.dataset, - version: options.datasetVersion, - configBaseDir: configIO.getConfigRoot(), - onProgress: (phase, msg) => onProgress?.(phase, msg), - }); - - const successfulResults = datasetResult.scenarioResults.filter(r => r.status === 'success'); - if (successfulResults.length === 0) { - return { - success: false, - error: new Error('All scenarios failed during invocation. No sessions to evaluate.'), - results: [], - logFilePath: logger?.logFilePath, - }; - } - - datasetSessionIds = successfulResults.map(r => r.sessionId); - - // Build sessionMetadata with ground truth from dataset - datasetMetadata = successfulResults.map(r => { - const scenario = datasetResult.scenarios.find(s => s.scenario_id === r.scenarioId); - return { - sessionId: r.sessionId, - testScenarioId: r.scenarioId, - groundTruth: scenario - ? { - inline: { - ...(scenario.assertions ? { assertions: scenario.assertions.map(a => ({ text: a })) } : {}), - ...(scenario.expected_trajectory - ? { expectedTrajectory: { toolNames: scenario.expected_trajectory } } - : {}), - ...(scenario.turns.some(t => t.expectedResponse) - ? { - turns: scenario.turns.map(t => ({ - input: { prompt: t.input }, - ...(t.expectedResponse ? { expectedResponse: { text: t.expectedResponse } } : {}), - })), - } - : {}), - }, - } - : undefined, - }; - }) as SessionMetadataEntry[]; - - onProgress?.('invoking', `✓ ${successfulResults.length} sessions ready for batch evaluation`); - - // Wait for CloudWatch span ingestion before submitting — the batch service - // queries CloudWatch server-side, so we can't poll. Match SDK default (180s). - onProgress?.('ingesting', 'Waiting 180s for CloudWatch span ingestion...'); - await sleep(BATCH_INGESTION_DELAY_MS); - } - - // Build optional filter config for CloudWatch filtering - // API requires either sessionIds OR timeRange, not both — sessionIds takes precedence - // Merge explicit sessionIds with any sessionIds from sessionMetadata (deduplicated) - const metadataSessionIds = options.sessionMetadata?.map(m => m.sessionId).filter(Boolean) ?? []; - const explicitSessionIds = [...(options.sessionIds ?? []), ...datasetSessionIds]; - const effectiveSessionIds = [...new Set([...explicitSessionIds, ...metadataSessionIds])]; - const hasSessionIds = effectiveSessionIds.length > 0; - - const filterConfig: CloudWatchFilterConfig | undefined = (() => { - if (hasSessionIds) { - return { sessionIds: effectiveSessionIds }; - } - if (options.lookbackDays) { - const endTime = new Date().toISOString(); - const startTime = new Date(Date.now() - options.lookbackDays * 24 * 60 * 60 * 1000).toISOString(); - return { timeRange: { startTime, endTime } }; - } - return undefined; - })(); - - // Merge dataset metadata with any explicit sessionMetadata - const allSessionMetadata = [...(options.sessionMetadata ?? []), ...datasetMetadata]; - - const startPayload = { - region, - name: evalName, - evaluators: resolvedEvaluators.map(id => ({ evaluatorId: id })), - dataSourceConfig: { - cloudWatchLogs: { - serviceNames: [serviceName], - logGroupNames: [runtimeLogGroupName], - ...(filterConfig ? { filterConfig } : {}), - }, - }, - ...(allSessionMetadata.length > 0 ? { evaluationMetadata: { sessionMetadata: allSessionMetadata } } : {}), - clientToken: generateClientToken(), - }; - - logger?.log(`Request payload:\n${JSON.stringify(startPayload, null, 2)}`); - - const startResult = await startBatchEvaluation(startPayload); - - logger?.log(`Response: ${JSON.stringify(startResult, null, 2)}`); - logger?.endStep('success'); - - onProgress?.('running', `Batch evaluation started (ID: ${startResult.batchEvaluationId})`); - onProgress?.('running', 'This may take a few minutes...'); - options.onStarted?.({ batchEvaluationId: startResult.batchEvaluationId, region }); - - // 4. Poll for completion - logger?.startStep('Poll for completion'); - let current: GetBatchEvaluationResult = { - batchEvaluationId: startResult.batchEvaluationId, - batchEvaluationArn: startResult.batchEvaluationArn, - name: startResult.name, - status: startResult.status, - }; - - while (!TERMINAL_STATUSES.has(current.status)) { - await sleep(pollIntervalMs); - - current = await getBatchEvaluation({ - region, - batchEvaluationId: startResult.batchEvaluationId, - }); - - onProgress?.('polling', `Status: ${current.status}`); - logger?.log(`Poll status: ${current.status}`); - } - - if (current.status !== 'COMPLETED' && current.status !== 'COMPLETED_WITH_ERRORS') { - const reasons = current.errorDetails?.join('; ') ?? ''; - const error = `Batch evaluation finished with status: ${current.status}${reasons ? ` — ${reasons}` : ''}`; - logger?.log(error, 'error'); - logger?.log(`Full poll response:\n${JSON.stringify(current, null, 2)}`, 'error'); - logger?.endStep('error', error); - logger?.finalize(false); - return { - success: false, - error: new Error(error), - batchEvaluationId: startResult.batchEvaluationId, - name: evalName, - status: current.status, - results: [], - logFilePath: logger?.logFilePath, - }; - } - - logger?.endStep('success'); - - // 5. Fetch results from CloudWatch output logs - logger?.startStep('Fetch results'); - let results: BatchEvaluationResult[] = []; - - const cwDest = current.outputConfig?.cloudWatchConfig; - if (cwDest) { - try { - results = await fetchResultsFromCloudWatch(region, cwDest.logGroupName, cwDest.logStreamName); - logger?.log(`Fetched ${results.length} result(s) from CloudWatch`); - } catch (cwErr: unknown) { - logger?.log(`Failed to fetch CW results: ${cwErr instanceof Error ? cwErr.message : String(cwErr)}`, 'error'); - } - } - - logger?.endStep('success'); - - logger?.log(`Results: ${JSON.stringify(results, null, 2)}`); - logger?.finalize(true); - - return { - success: true, - batchEvaluationId: startResult.batchEvaluationId, - name: evalName, - status: current.status, - results, - evaluationResults: current.evaluationResults, - startedAt: current.createdAt, - completedAt: current.updatedAt ?? new Date().toISOString(), - logFilePath: logger?.logFilePath, - }; - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - logger?.log(error, 'error'); - logger?.finalize(false); - return { success: false, error: toError(err), results: [], logFilePath: logger?.logFilePath }; - } -} - -// ============================================================================ -// Helpers -// ============================================================================ - -function resolveAgentState( - deployedState: DeployedState, - agentName: string -): { runtimeId: string; runtimeArn: string; roleArn?: string } | undefined { - for (const target of Object.values(deployedState.targets)) { - const agent = target.resources?.runtimes?.[agentName]; - if (agent) return agent; - } - return undefined; -} - -async function fetchResultsFromCloudWatch( - region: string, - logGroupName: string, - logStreamName: string -): Promise { - const client = new CloudWatchLogsClient({ region }); - const response = await client.send( - new GetLogEventsCommand({ - logGroupName, - logStreamName, - startFromHead: true, - }) - ); - - const results: BatchEvaluationResult[] = []; - for (const event of response.events ?? []) { - if (!event.message) continue; - try { - const parsed = JSON.parse(event.message) as Record; - const attrs = (parsed.attributes ?? {}) as Record; - const evaluatorId = attrs['gen_ai.evaluation.name'] as string | undefined; - if (!evaluatorId) continue; - - results.push({ - evaluatorId, - score: attrs['gen_ai.evaluation.score.value'] as number | undefined, - label: attrs['gen_ai.evaluation.score.label'] as string | undefined, - explanation: attrs['gen_ai.evaluation.explanation'] as string | undefined, - }); - } catch { - // Skip non-JSON or malformed entries - } - } - return results; -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} diff --git a/src/cli/operations/fetch-access/__tests__/fetch-gateway-token.test.ts b/src/cli/operations/fetch-access/__tests__/fetch-gateway-token.test.ts index ac556ab24..43833ef7e 100644 --- a/src/cli/operations/fetch-access/__tests__/fetch-gateway-token.test.ts +++ b/src/cli/operations/fetch-access/__tests__/fetch-gateway-token.test.ts @@ -44,6 +44,7 @@ const baseProjectSpec = { ], runtimes: [], memories: [], + knowledgeBases: [], evaluators: [], onlineEvalConfigs: [], }; diff --git a/src/cli/operations/fetch-access/fetch-gateway-token.ts b/src/cli/operations/fetch-access/fetch-gateway-token.ts index d02b4ba78..6c5b6c385 100644 --- a/src/cli/operations/fetch-access/fetch-gateway-token.ts +++ b/src/cli/operations/fetch-access/fetch-gateway-token.ts @@ -30,8 +30,7 @@ export async function fetchGatewayToken( ); } - const deployedGateways = target.resources?.mcp?.gateways ?? {}; - const deployedGateway = deployedGateways[gatewayName]; + const deployedGateway = target.resources?.gateways?.[gatewayName] ?? target.resources?.mcp?.gateways?.[gatewayName]; if (!deployedGateway?.gatewayUrl) { throw new Error( `Gateway '${gatewayName}' does not have a deployed URL. Run \`agentcore deploy\` to deploy the gateway.` diff --git a/src/cli/operations/fetch-access/list-gateways.ts b/src/cli/operations/fetch-access/list-gateways.ts index 0e70559ce..e98aeba6b 100644 --- a/src/cli/operations/fetch-access/list-gateways.ts +++ b/src/cli/operations/fetch-access/list-gateways.ts @@ -30,15 +30,15 @@ export async function listGateways( }); } - // Include HTTP gateways (auto-created for A/B testing) - const deployedHttpGateways = target.resources?.httpGateways ?? {}; - for (const httpGateway of projectSpec.httpGateways ?? []) { - const deployed = deployedHttpGateways[httpGateway.name]; + // Include HTTP gateways (deployed via CFN under resources.gateways) + const deployedHttpGateways = target.resources?.gateways ?? {}; + for (const gateway of projectSpec.agentCoreGateways.filter(g => g.protocolType === 'None')) { + const deployed = deployedHttpGateways[gateway.name]; if (!deployed?.gatewayArn) continue; gateways.push({ - name: httpGateway.name, - authType: 'AWS_IAM', + name: gateway.name, + authType: gateway.authorizerType ?? 'NONE', }); } diff --git a/src/cli/operations/harness/__tests__/orphan.test.ts b/src/cli/operations/harness/__tests__/orphan.test.ts new file mode 100644 index 000000000..a9bcf1739 --- /dev/null +++ b/src/cli/operations/harness/__tests__/orphan.test.ts @@ -0,0 +1,92 @@ +import type { DeployedState, HarnessDeployedState } from '../../../../schema'; +import { findOrphanHarnesses, isOrphanHarnessRecord, regionFromHarnessArn } from '../orphan'; +import { describe, expect, it } from 'vitest'; + +const cfnRecord: HarnessDeployedState = { + harnessId: 'h-cfn', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/h-cfn', + roleArn: 'arn:aws:iam::111122223333:role/cfn', + status: 'READY', + provisioner: 'cloudformation', +}; + +const orphanRecord: HarnessDeployedState = { + harnessId: 'h-orphan', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/h-orphan', + roleArn: 'arn:aws:iam::111122223333:role/orphan', + status: 'READY', +}; + +function stateWith(harnesses: Record, targetName = 'default'): DeployedState { + return { targets: { [targetName]: { resources: { stackName: 'S', harnesses } } } }; +} + +describe('isOrphanHarnessRecord', () => { + it('treats a record without the cloudformation marker as an orphan', () => { + expect(isOrphanHarnessRecord(orphanRecord)).toBe(true); + }); + + it('treats a cloudformation-marked record as not an orphan', () => { + expect(isOrphanHarnessRecord(cfnRecord)).toBe(false); + }); + + it('treats an absent record as not an orphan', () => { + expect(isOrphanHarnessRecord(undefined)).toBe(false); + }); +}); + +describe('regionFromHarnessArn', () => { + it('parses the region segment', () => { + expect(regionFromHarnessArn('arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/h')).toBe('us-west-2'); + }); + + it('returns undefined for a malformed ARN with no region', () => { + expect(regionFromHarnessArn('arn:aws:bedrock-agentcore::111122223333:harness/h')).toBeUndefined(); + expect(regionFromHarnessArn('not-an-arn')).toBeUndefined(); + }); +}); + +describe('findOrphanHarnesses', () => { + it('returns only unmarked records, with id/arn/region populated', () => { + const state = stateWith({ keep: cfnRecord, old: orphanRecord }); + const orphans = findOrphanHarnesses(state); + expect(orphans).toHaveLength(1); + expect(orphans[0]).toMatchObject({ + name: 'old', + targetName: 'default', + harnessId: 'h-orphan', + region: 'us-west-2', + }); + }); + + it('filters to a specific harness name when provided', () => { + const state = stateWith({ old: orphanRecord, alsoOld: { ...orphanRecord, harnessId: 'h2' } }); + expect(findOrphanHarnesses(state, 'old')).toHaveLength(1); + expect(findOrphanHarnesses(state, 'old')[0]!.name).toBe('old'); + }); + + it('returns nothing when every record carries the cloudformation marker', () => { + expect(findOrphanHarnesses(stateWith({ keep: cfnRecord }))).toEqual([]); + }); + + it('skips orphan records whose ARN has no parseable region (cannot be safely deleted)', () => { + const bad: HarnessDeployedState = { ...orphanRecord, harnessArn: 'not-an-arn' }; + expect(findOrphanHarnesses(stateWith({ bad }))).toEqual([]); + }); + + it('finds orphans across multiple targets', () => { + const state: DeployedState = { + targets: { + default: { resources: { stackName: 'S1', harnesses: { a: orphanRecord } } }, + prod: { resources: { stackName: 'S2', harnesses: { b: { ...orphanRecord, harnessId: 'h-b' } } } }, + }, + }; + const orphans = findOrphanHarnesses(state); + expect(orphans.map(o => o.targetName).sort()).toEqual(['default', 'prod']); + }); + + it('handles missing/empty deployed state', () => { + expect(findOrphanHarnesses(undefined)).toEqual([]); + expect(findOrphanHarnesses({ targets: {} })).toEqual([]); + }); +}); diff --git a/src/cli/operations/harness/orphan.ts b/src/cli/operations/harness/orphan.ts new file mode 100644 index 000000000..980a27443 --- /dev/null +++ b/src/cli/operations/harness/orphan.ts @@ -0,0 +1,76 @@ +import type { DeployedState, HarnessDeployedState } from '../../../schema'; + +/** + * Harness orphan cleanup (transitional, preview→GA). + * + * An "orphan" is a harness recorded in deployed-state.json that was created by the old + * imperative preview build: a real AWS::BedrockAgentCore::Harness that is NOT part of any + * CloudFormation stack. The GA path provisions every harness via CloudFormation and stamps + * its deployed-state record with `provisioner: 'cloudformation'`. A record WITHOUT that + * marker can only have come from the imperative build, so the marker is a purely local + * discriminator — orphan detection makes no AWS calls. + * + * CloudFormation cannot delete a resource it never created, so an orphan keeps billing and + * blocks a same-named CFN deploy (the create would 409/rollback). The CLI never auto-deletes + * it; detection only decides whether to warn the user and route deletion through + * `agentcore remove harness `. + * + * This whole module is self-terminating: once orphans are cleaned up and the project is + * redeployed, every record carries the marker and `findOrphanHarnesses` returns nothing. It + * is built to be deleted after the deprecation window. + */ + +/** A located orphan harness, carrying the recorded identifiers needed to delete it. */ +export interface OrphanHarness { + /** Harness name (key under resources.harnesses). */ + name: string; + /** Deployment target the record lives under. */ + targetName: string; + /** Control-plane harness id, used for the DeleteHarness call. */ + harnessId: string; + /** Recorded harness ARN; its region segment is authoritative for the delete. */ + harnessArn: string; + /** Region parsed from the recorded ARN (never re-resolved by name). */ + region: string; +} + +/** + * A deployed-state harness record is an orphan when it exists but lacks the + * `provisioner: 'cloudformation'` marker stamped by the CDK deploy path. + */ +export function isOrphanHarnessRecord(record: HarnessDeployedState | undefined): boolean { + return !!record && record.provisioner !== 'cloudformation'; +} + +/** + * Parse the region segment from a harness ARN + * (arn:aws:bedrock-agentcore:::harness/). Returns undefined when the + * ARN is malformed so callers can skip rather than issue a control-plane call to the wrong + * (or empty) region. + */ +export function regionFromHarnessArn(harnessArn: string): string | undefined { + const region = harnessArn.split(':')[3]; + return region && region.length > 0 ? region : undefined; +} + +/** + * Find orphan harnesses across all deployment targets in deployed-state. Reads local state + * only — no AWS calls. When `harnessName` is given, restricts the search to that name. + * + * Records whose ARN has no parseable region are skipped (they can't be safely deleted), so + * every returned orphan has the identifiers a deletion needs. + */ +export function findOrphanHarnesses(deployedState: DeployedState | undefined, harnessName?: string): OrphanHarness[] { + const orphans: OrphanHarness[] = []; + for (const [targetName, target] of Object.entries(deployedState?.targets ?? {})) { + const harnesses = target.resources?.harnesses ?? {}; + for (const [name, record] of Object.entries(harnesses)) { + if (harnessName && name !== harnessName) continue; + if (!isOrphanHarnessRecord(record)) continue; + const region = regionFromHarnessArn(record.harnessArn); + if (!region) continue; + orphans.push({ name, targetName, harnessId: record.harnessId, harnessArn: record.harnessArn, region }); + } + } + return orphans; +} diff --git a/src/cli/operations/harness/skill-utils.ts b/src/cli/operations/harness/skill-utils.ts new file mode 100644 index 000000000..f9a2313ec --- /dev/null +++ b/src/cli/operations/harness/skill-utils.ts @@ -0,0 +1,37 @@ +import type { AgentCoreProjectSpec, HarnessSpec } from '../../../schema'; +import { ValidationError } from '@/lib/errors/types'; +import type { Result } from '@/lib/result'; + +const KEY_SEPARATOR = '::'; + +export function getSkillKey(skill: HarnessSpec['skills'][number]): string { + if ('s3Uri' in skill) return `s3:${skill.s3Uri}`; + if ('gitUrl' in skill) return `git:${skill.gitUrl}${skill.path ? `${KEY_SEPARATOR}${skill.path}` : ''}`; + if ('awsSkills' in skill) return `awsSkills:${skill.awsSkills.paths?.slice().sort().join(',') ?? '*'}`; + return `path:${skill.path}`; +} + +export function buildGitSkillKey(gitUrl: string, gitPath?: string): string { + return `git:${gitUrl}${gitPath ? `${KEY_SEPARATOR}${gitPath}` : ''}`; +} + +export function validateGitSkillCredential(project: AgentCoreProjectSpec, credentialName: string): Result { + const credential = project.credentials.find(c => c.name === credentialName); + if (!credential) { + return { + success: false, + error: new ValidationError( + `Credential '${credentialName}' not found in project. Run 'agentcore add credential' first.` + ), + }; + } + if (credential.authorizerType !== 'ApiKeyCredentialProvider') { + return { + success: false, + error: new ValidationError( + `Credential '${credentialName}' is type '${credential.authorizerType}'. Git skill auth requires an ApiKeyCredentialProvider credential.` + ), + }; + } + return { success: true }; +} diff --git a/src/cli/operations/ingest/__tests__/index.test.ts b/src/cli/operations/ingest/__tests__/index.test.ts new file mode 100644 index 000000000..15821a100 --- /dev/null +++ b/src/cli/operations/ingest/__tests__/index.test.ts @@ -0,0 +1,296 @@ +import * as bedrockAgent from '../../../aws/bedrock-agent'; +import { runKbIngestionByName } from '../index'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../aws/bedrock-agent'); + +function deployedState(kbId: string, dataSourceIds: string[]) { + return { + targets: { + default: { + resources: { + knowledgeBases: { + docs: { + knowledgeBaseId: kbId, + knowledgeBaseArn: `arn:aws:bedrock:us-west-2:0:knowledge-base/${kbId}`, + dataSources: dataSourceIds.map(dsId => ({ + dataSourceId: dsId, + uri: `s3://b/${dsId}/`, + })), + }, + }, + }, + }, + }, + } as never; +} + +describe('runKbIngestionByName', () => { + beforeEach(() => vi.mocked(bedrockAgent.startIngestionJob).mockReset()); + afterEach(() => vi.restoreAllMocks()); + + it('starts an ingestion job per data source and returns their IDs in order', async () => { + vi.mocked(bedrockAgent.startIngestionJob) + .mockResolvedValueOnce({ ingestionJobId: 'IJ-1', status: 'STARTING' } as never) + .mockResolvedValueOnce({ ingestionJobId: 'IJ-2', status: 'STARTING' } as never); + + const result = await runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: deployedState('KB1', ['DS1', 'DS2']), + targetName: 'default', + region: 'us-west-2', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.startedJobs).toEqual([ + { dataSourceId: 'DS1', uri: 's3://b/DS1/', ingestionJobId: 'IJ-1' }, + { dataSourceId: 'DS2', uri: 's3://b/DS2/', ingestionJobId: 'IJ-2' }, + ]); + expect(bedrockAgent.startIngestionJob).toHaveBeenCalledTimes(2); + }); + + function deployedStateWithUris(kbId: string, dataSources: { dataSourceId: string; uri: string }[]) { + return { + targets: { + default: { + resources: { + knowledgeBases: { + docs: { + knowledgeBaseId: kbId, + knowledgeBaseArn: `arn:aws:bedrock:us-west-2:0:knowledge-base/${kbId}`, + dataSources, + }, + }, + }, + }, + }, + } as never; + } + + it('ingests only the named data source when dataSourceUri is set', async () => { + vi.mocked(bedrockAgent.startIngestionJob).mockResolvedValueOnce({ + ingestionJobId: 'IJ-2', + status: 'STARTING', + } as never); + + const result = await runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: deployedStateWithUris('KB1', [ + { dataSourceId: 'DS-1', uri: 's3://a/' }, + { dataSourceId: 'DS-2', uri: 's3://b/' }, + ]), + targetName: 'default', + region: 'us-west-2', + dataSourceUri: 's3://b/', + concurrentRetryPolicy: { maxAttempts: 1, delayMs: 0 }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.startedJobs).toHaveLength(1); + expect(result.startedJobs[0]?.uri).toBe('s3://b/'); + expect(bedrockAgent.startIngestionJob).toHaveBeenCalledTimes(1); + }); + + it('errors when the named data source is not found', async () => { + const result = await runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: deployedStateWithUris('KB1', [ + { dataSourceId: 'DS-1', uri: 's3://a/' }, + { dataSourceId: 'DS-2', uri: 's3://b/' }, + ]), + targetName: 'default', + region: 'us-west-2', + dataSourceUri: 's3://nope/', + concurrentRetryPolicy: { maxAttempts: 1, delayMs: 0 }, + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/s3:\/\/nope\//); + expect(bedrockAgent.startIngestionJob).not.toHaveBeenCalled(); + }); + + it("errors when the KB hasn't been deployed yet", async () => { + const empty = { targets: { default: { resources: { knowledgeBases: {} } } } } as never; + const result = await runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: empty, + targetName: 'default', + region: 'us-west-2', + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/has not been deployed/i); + }); + + it('errors when no data sources are recorded', async () => { + const noDs = deployedState('KB1', []); + const result = await runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: noDs, + targetName: 'default', + region: 'us-west-2', + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/no data sources/i); + }); + + it('reports a partial-failure result if one DS fails (other still started)', async () => { + vi.mocked(bedrockAgent.startIngestionJob) + .mockResolvedValueOnce({ ingestionJobId: 'IJ-1', status: 'STARTING' } as never) + .mockRejectedValueOnce(new Error('Throttled')); + + const result = await runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: deployedState('KB1', ['DS1', 'DS2']), + targetName: 'default', + region: 'us-west-2', + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/1 of 2 data sources/i); + expect(result.error.message).toMatch(/Throttled/); + }); + + it('errors when the target name does not exist in deployed-state', async () => { + const state = deployedState('KB1', ['DS1']); + const result = await runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: state, + targetName: 'nonexistent', + region: 'us-west-2', + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/has not been deployed/i); + }); + + it('retries the concurrent-ingestion-limit error and eventually succeeds', async () => { + const conflictErr = Object.assign(new Error('You have reached the maximum number of concurrent ingestion jobs'), { + name: 'ConflictException', + }); + vi.mocked(bedrockAgent.startIngestionJob) + .mockResolvedValueOnce({ ingestionJobId: 'IJ-1', status: 'STARTING' } as never) + .mockRejectedValueOnce(conflictErr) + .mockRejectedValueOnce(conflictErr) + .mockResolvedValueOnce({ ingestionJobId: 'IJ-2', status: 'STARTING' } as never); + + const result = await runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: deployedState('KB1', ['DS1', 'DS2']), + targetName: 'default', + region: 'us-west-2', + concurrentRetryPolicy: { maxAttempts: 5, delayMs: 0 }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.startedJobs).toEqual([ + { dataSourceId: 'DS1', uri: 's3://b/DS1/', ingestionJobId: 'IJ-1' }, + { dataSourceId: 'DS2', uri: 's3://b/DS2/', ingestionJobId: 'IJ-2' }, + ]); + expect(bedrockAgent.startIngestionJob).toHaveBeenCalledTimes(4); + }); + + it('gives up after maxAttempts of concurrent-limit errors and reports the failure', async () => { + const conflictErr = Object.assign(new Error('You have reached the maximum number of concurrent ingestion jobs'), { + name: 'ConflictException', + }); + vi.mocked(bedrockAgent.startIngestionJob) + .mockResolvedValueOnce({ ingestionJobId: 'IJ-1', status: 'STARTING' } as never) + .mockRejectedValueOnce(conflictErr) + .mockRejectedValueOnce(conflictErr) + .mockRejectedValueOnce(conflictErr); + + const result = await runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: deployedState('KB1', ['DS1', 'DS2']), + targetName: 'default', + region: 'us-west-2', + concurrentRetryPolicy: { maxAttempts: 3, delayMs: 0 }, + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/1 of 2 data sources/i); + expect(result.error.message).toMatch(/concurrent ingestion jobs/i); + // 1 success + 3 retries on DS2 + expect(bedrockAgent.startIngestionJob).toHaveBeenCalledTimes(4); + }); + + it('does not retry non-concurrent errors', async () => { + vi.mocked(bedrockAgent.startIngestionJob) + .mockResolvedValueOnce({ ingestionJobId: 'IJ-1', status: 'STARTING' } as never) + .mockRejectedValueOnce(new Error('Throttled')); + + const result = await runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: deployedState('KB1', ['DS1', 'DS2']), + targetName: 'default', + region: 'us-west-2', + concurrentRetryPolicy: { maxAttempts: 5, delayMs: 0 }, + }); + + expect(result.success).toBe(false); + expect(bedrockAgent.startIngestionJob).toHaveBeenCalledTimes(2); + }); + + it('emits progress messages on each retry sleep', async () => { + const conflictErr = Object.assign(new Error('You have reached the maximum number of concurrent ingestion jobs'), { + name: 'ConflictException', + }); + vi.mocked(bedrockAgent.startIngestionJob) + .mockResolvedValueOnce({ ingestionJobId: 'IJ-1', status: 'STARTING' } as never) + .mockRejectedValueOnce(conflictErr) + .mockResolvedValueOnce({ ingestionJobId: 'IJ-2', status: 'STARTING' } as never); + + const messages: string[] = []; + const result = await runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: deployedState('KB1', ['DS1', 'DS2']), + targetName: 'default', + region: 'us-west-2', + concurrentRetryPolicy: { maxAttempts: 5, delayMs: 0 }, + onProgress: msg => messages.push(msg), + }); + + expect(result.success).toBe(true); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatch(/DS2.*another ingestion job is running/); + expect(messages[0]).toMatch(/retry 1\/4/); + }); + + it('honours an abort signal mid-sleep and reports the in-flight DS as failed', async () => { + const conflictErr = Object.assign(new Error('You have reached the maximum number of concurrent ingestion jobs'), { + name: 'ConflictException', + }); + // DS1 succeeds, DS2's first attempt rejects with conflict; the loop will + // sleep before retrying — we abort mid-sleep so the second attempt never + // fires. Only queue what gets consumed. + vi.mocked(bedrockAgent.startIngestionJob) + .mockResolvedValueOnce({ ingestionJobId: 'IJ-1', status: 'STARTING' } as never) + .mockRejectedValueOnce(conflictErr); + + const controller = new AbortController(); + const promise = runKbIngestionByName({ + knowledgeBaseName: 'docs', + deployedState: deployedState('KB1', ['DS1', 'DS2']), + targetName: 'default', + region: 'us-west-2', + concurrentRetryPolicy: { maxAttempts: 5, delayMs: 50 }, + signal: controller.signal, + }); + // Abort while DS2 is sleeping between retries. + setTimeout(() => controller.abort(), 5); + const result = await promise; + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/Aborted/); + // 1 success + 1 failed attempt; second retry never fires due to abort. + expect(bedrockAgent.startIngestionJob).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/cli/operations/ingest/index.ts b/src/cli/operations/ingest/index.ts new file mode 100644 index 000000000..37d8db50e --- /dev/null +++ b/src/cli/operations/ingest/index.ts @@ -0,0 +1,215 @@ +import { IngestionError } from '../../../lib'; +import type { Result } from '../../../lib/result'; +import type { DeployedState } from '../../../schema'; +import { startIngestionJob } from '../../aws/bedrock-agent'; + +export interface RunKbIngestionInput { + knowledgeBaseName: string; + deployedState: DeployedState; + targetName: string; + region: string; + /** + * When set, ingest only the data source whose recorded URI matches this + * value, instead of every data source on the KB. Returns a user error if no + * data source has this URI. + */ + dataSourceUri?: string; + /** + * Override the concurrent-limit retry policy. Defaults to {@link DEFAULT_CONCURRENT_RETRY_POLICY}. + * Tests pass a tight policy to keep runs fast. + */ + concurrentRetryPolicy?: ConcurrentRetryPolicy; + /** + * Optional callback for progress updates during the retry loop. Called when + * an attempt is rejected with a concurrent-limit error and we sleep before + * the next attempt. Surfaced to the user via deploy logger / stdout so a + * quiet 5-minute wait isn't mistaken for a hang. + */ + onProgress?: (message: string) => void; + /** + * Optional abort signal. When triggered, cancels any pending sleep between + * retries and returns failure for the in-flight DS. Already-started jobs + * are not rolled back. + */ + signal?: AbortSignal; +} + +export interface ConcurrentRetryPolicy { + /** Max attempts per data source (1 = no retry). */ + maxAttempts: number; + /** Delay before each retry, in milliseconds. */ + delayMs: number; +} + +/** + * Bedrock allows only one in-flight ingestion job per knowledge base. When we + * fire StartIngestionJob for multi-DS KBs the second+ call usually races and + * gets rejected with ConflictException. Retry up to 4 times (~5min total at + * 75s intervals) which comfortably covers the average ingestion-job duration + * for small S3 sources. + */ +export const DEFAULT_CONCURRENT_RETRY_POLICY: ConcurrentRetryPolicy = { + maxAttempts: 5, + delayMs: 75_000, +}; + +function isConcurrentLimitError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const e = err as { name?: string; message?: string }; + if (e.name === 'ConflictException') return true; + return typeof e.message === 'string' && /concurrent ingestion jobs/i.test(e.message); +} + +/** + * Sleep that resolves early if the abort signal fires. Resolves cleanly in + * either case so the caller can decide whether to honour the abort by checking + * `signal.aborted` afterwards. + */ +function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.resolve(); + return new Promise(resolve => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +export interface StartedIngestion { + dataSourceId: string; + uri: string; + ingestionJobId: string; +} + +export type RunKbIngestionResult = Result<{ startedJobs: StartedIngestion[] }, IngestionError>; + +/** + * Start ingestion for every data source on a deployed KB, returning the new + * ingestion-job IDs. + * + * Used by: + * - `agentcore run ingest --name X` for manual retry + * - The post-deploy auto-ingestion hook (when sourcesHash changed since last + * deploy) + * + * Pre-conditions: + * - The KB has been deployed (the deployed-state has a record for it). + * - At least one data source has been recorded in the deployed-state. + * + * Failure model: + * - Pre-flight failures (KB not deployed, no DSes) return success: false with + * errorSource overridden to 'user'. + * - Service failures bubble up the bedrock-agent error message; if any DS + * start fails, the function returns success: false and lists the failed + * DS(es) by ID. Successful starts are NOT rolled back. + */ +export async function runKbIngestionByName(input: RunKbIngestionInput): Promise { + const { knowledgeBaseName, deployedState, targetName, region, onProgress, signal } = input; + const retryPolicy = input.concurrentRetryPolicy ?? DEFAULT_CONCURRENT_RETRY_POLICY; + + const target = deployedState.targets[targetName]; + const deployed = target?.resources?.knowledgeBases?.[knowledgeBaseName]; + if (!deployed) { + return { + success: false, + error: new IngestionError( + `Knowledge base '${knowledgeBaseName}' has not been deployed to target '${targetName}'. Run 'agentcore deploy' first.`, + { errorSource: 'user' } + ), + }; + } + + if (deployed.dataSources.length === 0) { + return { + success: false, + error: new IngestionError(`Knowledge base '${knowledgeBaseName}' has no data sources to ingest.`, { + errorSource: 'user', + }), + }; + } + + let toIngest = deployed.dataSources; + if (input.dataSourceUri) { + toIngest = deployed.dataSources.filter(ds => ds.uri === input.dataSourceUri); + if (toIngest.length === 0) { + return { + success: false, + error: new IngestionError( + `Data source '${input.dataSourceUri}' not found on knowledge base '${knowledgeBaseName}'.`, + { errorSource: 'user' } + ), + }; + } + } + + const startedJobs: StartedIngestion[] = []; + const failures: { dataSourceId: string; uri: string; error: unknown }[] = []; + // Sequential to play nice with Bedrock's 1-concurrent-job-per-KB cap. When + // a start fails because a sibling job is still running, sleep and retry. + for (const ds of toIngest) { + if (signal?.aborted) { + failures.push({ + dataSourceId: ds.dataSourceId, + uri: ds.uri, + error: new Error('Aborted before start'), + }); + continue; + } + let lastError: unknown; + let started = false; + for (let attempt = 1; attempt <= retryPolicy.maxAttempts; attempt++) { + try { + const job = await startIngestionJob({ + region, + knowledgeBaseId: deployed.knowledgeBaseId, + dataSourceId: ds.dataSourceId, + }); + if (!job.ingestionJobId) { + lastError = new Error('No ingestionJobId in StartIngestionJob response'); + break; + } + startedJobs.push({ + dataSourceId: ds.dataSourceId, + uri: ds.uri, + ingestionJobId: job.ingestionJobId, + }); + started = true; + break; + } catch (err) { + lastError = err; + if (!isConcurrentLimitError(err) || attempt === retryPolicy.maxAttempts) break; + const delaySeconds = Math.round(retryPolicy.delayMs / 1000); + onProgress?.( + `${ds.dataSourceId} (${ds.uri}): another ingestion job is running on this KB, retry ${attempt}/${retryPolicy.maxAttempts - 1} in ${delaySeconds}s…` + ); + await sleep(retryPolicy.delayMs, signal); + if (signal?.aborted) { + lastError = new Error('Aborted while waiting for concurrent ingestion job to drain'); + break; + } + } + } + if (!started) { + failures.push({ dataSourceId: ds.dataSourceId, uri: ds.uri, error: lastError }); + } + } + + if (failures.length > 0) { + const detail = failures + .map(f => ` ${f.dataSourceId} (${f.uri}): ${f.error instanceof Error ? f.error.message : String(f.error)}`) + .join('\n'); + return { + success: false, + error: new IngestionError( + `Failed to start ingestion for ${failures.length} of ${toIngest.length} data sources:\n${detail}` + ), + }; + } + + return { success: true, startedJobs }; +} diff --git a/src/cli/operations/insights/index.ts b/src/cli/operations/insights/index.ts new file mode 100644 index 000000000..fabe6f3bc --- /dev/null +++ b/src/cli/operations/insights/index.ts @@ -0,0 +1,10 @@ +export { + saveInsightsRun, + loadInsightsRun, + listInsightsRuns, + deleteLocalInsightsRun, + updateInsightsRun, + INSIGHTS_DIR, +} from './insights-storage'; +export { runInsightsCommand } from './run-insights'; +export type { RunInsightsOptions, InsightsRunRecord, RunInsightsResult } from './types'; diff --git a/src/cli/operations/insights/insights-storage.ts b/src/cli/operations/insights/insights-storage.ts new file mode 100644 index 000000000..fb612e0a4 --- /dev/null +++ b/src/cli/operations/insights/insights-storage.ts @@ -0,0 +1,58 @@ +import { findConfigRoot } from '../../../lib'; +import type { InsightsRunRecord } from './types'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +export const INSIGHTS_DIR = 'insights'; + +function getInsightsDir(): string { + const configRoot = findConfigRoot(); + if (!configRoot) { + throw new Error('No agentcore project found. Run `agentcore create` first.'); + } + return join(configRoot, '.cli', INSIGHTS_DIR); +} + +export function saveInsightsRun(record: InsightsRunRecord): string { + const dir = getInsightsDir(); + mkdirSync(dir, { recursive: true }); + const id = record.batchEvaluationId; + const filePath = join(dir, `${id}.json`); + writeFileSync(filePath, JSON.stringify(record, null, 2)); + return filePath; +} + +export function loadInsightsRun(batchEvaluationId: string): InsightsRunRecord { + const dir = getInsightsDir(); + const jsonName = batchEvaluationId.endsWith('.json') ? batchEvaluationId : `${batchEvaluationId}.json`; + const filePath = join(dir, jsonName); + if (!existsSync(filePath)) { + throw new Error(`Insights run "${batchEvaluationId}" not found at ${filePath}`); + } + return JSON.parse(readFileSync(filePath, 'utf-8')) as InsightsRunRecord; +} + +export function listInsightsRuns(): InsightsRunRecord[] { + const dir = getInsightsDir(); + if (!existsSync(dir)) return []; + const files = readdirSync(dir) + .filter(f => f.endsWith('.json')) + .sort() + .reverse(); + return files.map(f => JSON.parse(readFileSync(join(dir, f), 'utf-8')) as InsightsRunRecord); +} + +export function deleteLocalInsightsRun(batchEvaluationId: string): boolean { + const dir = getInsightsDir(); + const filePath = join(dir, `${batchEvaluationId}.json`); + if (!existsSync(filePath)) return false; + rmSync(filePath); + return true; +} + +export function updateInsightsRun(batchEvaluationId: string, updates: Partial): void { + const record = loadInsightsRun(batchEvaluationId); + const updated: InsightsRunRecord = { ...record, ...updates }; + const dir = getInsightsDir(); + writeFileSync(join(dir, `${batchEvaluationId}.json`), JSON.stringify(updated, null, 2)); +} diff --git a/src/cli/operations/insights/run-insights.ts b/src/cli/operations/insights/run-insights.ts new file mode 100644 index 000000000..4b443e0d2 --- /dev/null +++ b/src/cli/operations/insights/run-insights.ts @@ -0,0 +1,231 @@ +/** + * Orchestrates running an Insights job: + * 1. Resolve agent from deployed state (for serviceNames / logGroupNames) + * 2. Build insights + dataSourceConfig + * 3. Call StartBatchEvaluation + * 4. Optionally poll GetBatchEvaluation until terminal status + * 5. Return results + */ +import { ConfigIO, ResourceNotFoundError, toError } from '../../../lib'; +import type { DeployedState } from '../../../schema'; +import type { CloudWatchFilterConfig, InsightConfig } from '../../aws/agentcore-batch-evaluation'; +import { generateClientToken, getBatchEvaluation, startBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; +import { resolveEndpointName, runtimeLogGroup } from '../../aws/cloudwatch'; +import { getRegion } from '../../commands/shared/region-utils'; +import { ExecLogger } from '../../logging/exec-logger'; +import { saveInsightsRun, updateInsightsRun } from './insights-storage'; +import type { InsightsRunRecord, RunInsightsOptions, RunInsightsResult } from './types'; + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_POLL_INTERVAL_MS = 5_000; +const DEFAULT_LOOKBACK_DAYS = 7; +const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'COMPLETED_WITH_ERRORS', 'STOPPED', 'CANCELLED']); + +// ============================================================================ +// Implementation +// ============================================================================ + +export async function runInsightsCommand(options: RunInsightsOptions): Promise { + let logger: ExecLogger | undefined; + try { + logger = new ExecLogger({ command: 'insights' }); + } catch { + // Non-fatal + } + + try { + // 1. Load project config + deployed state + logger?.startStep('Load project config'); + const configIO = new ConfigIO(); + const [projectSpec, deployedState] = await Promise.all([configIO.readProjectSpec(), configIO.readDeployedState()]); + + const region = await getRegion(options.region); + logger?.log(`Region: ${region}`); + logger?.endStep('success'); + + // 2. Build dataSourceConfig + logger?.startStep('Build data source config'); + let dataSourceConfig: { + cloudWatchLogs?: { + serviceNames: string[]; + logGroupNames: string[]; + filterConfig?: CloudWatchFilterConfig; + }; + onlineEvaluationConfigSource?: { onlineEvaluationConfigArn: string }; + }; + + if (options.onlineEvalConfigArn) { + // Online evaluation config source mode + dataSourceConfig = { + onlineEvaluationConfigSource: { onlineEvaluationConfigArn: options.onlineEvalConfigArn }, + }; + logger?.log(`Using onlineEvaluationConfigSource: ${options.onlineEvalConfigArn}`); + } else { + // CloudWatch logs mode — requires agent + if (!options.agent) { + const error = 'Agent name is required when not using --online-eval-config-arn'; + logger?.log(error, 'error'); + logger?.endStep('error', error); + logger?.finalize(false); + return { success: false, error: new ResourceNotFoundError(error), logFilePath: logger?.logFilePath }; + } + + const agentState = resolveAgentState(deployedState, options.agent); + if (!agentState) { + const error = `Agent "${options.agent}" not deployed. Run \`agentcore deploy\` first.`; + logger?.log(error, 'error'); + logger?.endStep('error', error); + logger?.finalize(false); + return { success: false, error: new ResourceNotFoundError(error), logFilePath: logger?.logFilePath }; + } + + const runtimeId = agentState.runtimeId; + const endpointName = resolveEndpointName(options.endpoint); + const serviceName = `${projectSpec.name}_${options.agent}.${endpointName}`; + const logGroupName = runtimeLogGroup(runtimeId, options.endpoint); + + logger?.log(`Agent: ${options.agent} (runtime: ${runtimeId})`); + logger?.log(`Service name: ${serviceName}`); + logger?.log(`Log group: ${logGroupName}`); + + // Build filterConfig from lookbackDays/startTime/endTime/sessionIds + const filterConfig = buildFilterConfig(options); + + dataSourceConfig = { + cloudWatchLogs: { + serviceNames: [serviceName], + logGroupNames: [logGroupName], + ...(filterConfig ? { filterConfig } : {}), + }, + }; + } + logger?.endStep('success'); + + // 3. Build insights array + const insights: InsightConfig[] = options.insights.map(id => ({ insightId: id })); + + // 4. Generate name if not provided + const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const rand = Math.random().toString(36).slice(2, 8); + const name = options.name ?? `${options.agent ?? 'insights'}_insights_${dateStr}_${rand}`; + + // 5. Call startBatchEvaluation + logger?.startStep('Start insights job'); + options.onProgress?.('starting', `Starting insights job "${name}"...`); + + const evaluators = options.evaluators?.map(id => ({ evaluatorId: id })); + + const startPayload = { + region, + name, + insights, + ...(evaluators?.length && { evaluators }), + dataSourceConfig, + clientToken: generateClientToken(), + }; + + logger?.log(`Request payload:\n${JSON.stringify(startPayload, null, 2)}`); + const startResult = await startBatchEvaluation(startPayload); + logger?.log(`Response: ${JSON.stringify(startResult, null, 2)}`); + logger?.endStep('success'); + + options.onProgress?.('running', `Insights job started (ID: ${startResult.batchEvaluationId})`); + options.onStarted?.({ batchEvaluationId: startResult.batchEvaluationId, region }); + + // 6. Save initial record + const record: InsightsRunRecord = { + batchEvaluationId: startResult.batchEvaluationId, + batchEvaluationArn: startResult.batchEvaluationArn, + name: startResult.name, + status: startResult.status, + region, + createdAt: startResult.createdAt, + insights: options.insights, + agent: options.agent, + }; + saveInsightsRun(record); + + // 7. If wait mode — poll until terminal + if (options.wait) { + logger?.startStep('Poll for completion'); + const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + + while (!TERMINAL_STATUSES.has(record.status)) { + await sleep(pollInterval); + + const current = await getBatchEvaluation({ + region, + batchEvaluationId: startResult.batchEvaluationId, + }); + + record.status = current.status; + if (current.evaluationResults) { + record.sessionCount = current.evaluationResults.totalNumberOfSessions; + record.sessionsCompleted = current.evaluationResults.numberOfSessionsCompleted; + record.sessionsFailed = current.evaluationResults.numberOfSessionsFailed; + } + if (TERMINAL_STATUSES.has(current.status)) { + record.completedAt = current.updatedAt; + } + updateInsightsRun(startResult.batchEvaluationId, record); + options.onProgress?.(current.status, `Status: ${current.status}`); + logger?.log(`Poll status: ${current.status}`); + } + logger?.endStep('success'); + } + + logger?.finalize(true); + + // 8. Return result + return { + success: true, + batchEvaluationId: record.batchEvaluationId, + batchEvaluationArn: record.batchEvaluationArn, + name: record.name, + status: record.status, + region, + sessionCount: record.sessionCount, + sessionsCompleted: record.sessionsCompleted, + sessionsFailed: record.sessionsFailed, + logFilePath: logger?.logFilePath, + }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + logger?.log(error, 'error'); + logger?.finalize(false); + return { success: false, error: toError(err), logFilePath: logger?.logFilePath }; + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function resolveAgentState( + deployedState: DeployedState, + agentName: string +): { runtimeId: string; runtimeArn: string; roleArn?: string } | undefined { + for (const target of Object.values(deployedState.targets)) { + const agent = target.resources?.runtimes?.[agentName]; + if (agent) return agent; + } + return undefined; +} + +function buildFilterConfig(options: RunInsightsOptions): CloudWatchFilterConfig | undefined { + if (options.sessionIds && options.sessionIds.length > 0) { + return { sessionIds: options.sessionIds }; + } + // Use explicit startTime/endTime if provided, otherwise fall back to lookbackDays + const lookbackDays = options.lookbackDays ?? DEFAULT_LOOKBACK_DAYS; + const endTime = options.endTime ?? new Date().toISOString(); + const startTime = options.startTime ?? new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString(); + return { timeRange: { startTime, endTime } }; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/cli/operations/insights/types.ts b/src/cli/operations/insights/types.ts new file mode 100644 index 000000000..18267437e --- /dev/null +++ b/src/cli/operations/insights/types.ts @@ -0,0 +1,46 @@ +import type { Result } from '../../../lib/result'; + +export interface RunInsightsOptions { + agent?: string; + insights: string[]; + /** Optional evaluator — required if chaining into `run recommendation --from-insights` */ + evaluators?: string[]; + onlineEvalConfigArn?: string; + lookbackDays?: number; + startTime?: string; + endTime?: string; + sessionIds?: string[]; + name?: string; + region?: string; + endpoint?: string; + wait?: boolean; + pollIntervalMs?: number; + onProgress?: (status: string, message: string) => void; + onStarted?: (info: { batchEvaluationId: string; region: string }) => void; +} + +export interface InsightsRunRecord { + batchEvaluationId: string; + batchEvaluationArn: string; + name: string; + status: string; + region: string; + createdAt?: string; + completedAt?: string; + insights: string[]; + agent?: string; + sessionCount?: number; + sessionsCompleted?: number; + sessionsFailed?: number; +} + +export type RunInsightsResult = Result<{ + batchEvaluationId: string; + batchEvaluationArn: string; + name: string; + status: string; + region: string; + sessionCount?: number; + sessionsCompleted?: number; + sessionsFailed?: number; +}> & { logFilePath?: string }; diff --git a/src/cli/operations/invoke/__tests__/resolve-agent-context.test.ts b/src/cli/operations/invoke/__tests__/resolve-agent-context.test.ts index 67f360ee1..df6c83fa2 100644 --- a/src/cli/operations/invoke/__tests__/resolve-agent-context.test.ts +++ b/src/cli/operations/invoke/__tests__/resolve-agent-context.test.ts @@ -12,6 +12,7 @@ const mockProject = { managedBy: 'CDK' as const, runtimes: [{ name: 'MyAgent', build: 'CodeZip' as const, entrypoint: 'main.py', codeLocation: 'app/MyAgent/' }], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], diff --git a/src/cli/operations/jobs/ab-test/__tests__/build-options.test.ts b/src/cli/operations/jobs/ab-test/__tests__/build-options.test.ts new file mode 100644 index 000000000..eee9897cc --- /dev/null +++ b/src/cli/operations/jobs/ab-test/__tests__/build-options.test.ts @@ -0,0 +1,190 @@ +import type { DeployedResourceState } from '../../../../../schema'; +import type { StartABTestJobOptions } from '../../shared/types'; +import { buildABTestRequest } from '../build-options'; +import { describe, expect, it } from 'vitest'; + +const deployed: DeployedResourceState = { + configBundles: { + promptA: { bundleId: 'b-a', bundleArn: 'arn:aws:bedrock-agentcore:us-east-1:1:config-bundle/a', versionId: 'v7' }, + promptB: { bundleId: 'b-b', bundleArn: 'arn:aws:bedrock-agentcore:us-east-1:1:config-bundle/b', versionId: 'v3' }, + }, + onlineEvalConfigs: { + quality: { + onlineEvaluationConfigId: 'oe-1', + onlineEvaluationConfigArn: 'arn:aws:bedrock-agentcore:us-east-1:1:online-evaluation-config/q', + }, + }, +}; + +function baseOpts(overrides: Partial): StartABTestJobOptions { + return { + name: 't', + mode: 'config-bundle', + gateway: 'gw', + controlWeight: 50, + treatmentWeight: 50, + ...overrides, + }; +} + +describe('buildABTestRequest', () => { + describe('config-bundle mode', () => { + it('resolves bundle names to ARNs and LATEST to the deployed versionId', () => { + const built = buildABTestRequest( + baseOpts({ + controlBundle: 'promptA', + controlVersion: 'LATEST', + treatmentBundle: 'promptB', + treatmentVersion: 'v9', + onlineEval: 'quality', + }), + deployed + ); + + expect(built.variants).toHaveLength(2); + expect(built.variants[0]).toMatchObject({ + name: 'C', + weight: 50, + variantConfiguration: { + configurationBundle: { bundleArn: deployed.configBundles!.promptA!.bundleArn, bundleVersion: 'v7' }, + }, + }); + expect(built.variants[1]!.variantConfiguration.configurationBundle).toEqual({ + bundleArn: deployed.configBundles!.promptB!.bundleArn, + bundleVersion: 'v9', // explicit version is not expanded + }); + expect(built.evaluationConfig).toEqual({ + onlineEvaluationConfigArn: deployed.onlineEvalConfigs!.quality!.onlineEvaluationConfigArn, + }); + expect(built.variantSummaries[0]).toMatchObject({ name: 'C', bundleVersion: 'v7' }); + }); + + it('throws when a required bundle field is missing', () => { + expect(() => + buildABTestRequest(baseOpts({ controlBundle: 'promptA', onlineEval: 'quality' }), deployed) + ).toThrow(); + }); + + it('throws when the online-eval config is missing', () => { + expect(() => + buildABTestRequest( + baseOpts({ + controlBundle: 'promptA', + controlVersion: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + treatmentBundle: 'promptB', + treatmentVersion: 'a1b2c3d4-e5f6-7890-abcd-ef1234567891', + }), + deployed + ) + ).toThrow(); + }); + }); + + describe('target-based mode', () => { + it('uses target names as-is and builds per-variant eval config', () => { + const built = buildABTestRequest( + baseOpts({ + mode: 'target-based', + controlTarget: 'ctrl', + treatmentTarget: 'treat', + controlOnlineEval: 'quality', + treatmentOnlineEval: 'quality', + }), + deployed + ); + expect(built.variants[0]!.variantConfiguration.target).toEqual({ name: 'ctrl' }); + expect(built.variants[1]!.variantConfiguration.target).toEqual({ name: 'treat' }); + }); + + it('builds per-variant eval config from control + treatment evals', () => { + const built = buildABTestRequest( + baseOpts({ + mode: 'target-based', + controlTarget: 'ctrl', + treatmentTarget: 'treat', + controlOnlineEval: 'quality', + treatmentOnlineEval: 'quality', + }), + deployed + ); + expect(built.evaluationConfig).toHaveProperty('perVariantOnlineEvaluationConfig'); + const perVariant = (built.evaluationConfig as { perVariantOnlineEvaluationConfig: unknown[] }) + .perVariantOnlineEvaluationConfig; + expect(perVariant).toHaveLength(2); + }); + + it('throws when control online eval is missing', () => { + expect(() => + buildABTestRequest( + baseOpts({ + mode: 'target-based', + controlTarget: 'ctrl', + treatmentTarget: 'treat', + treatmentOnlineEval: 'quality', + }), + deployed + ) + ).toThrow(/control-online-eval/); + }); + + it('throws when both evals are missing', () => { + expect(() => + buildABTestRequest( + baseOpts({ mode: 'target-based', controlTarget: 'ctrl', treatmentTarget: 'treat' }), + deployed + ) + ).toThrow(/control-online-eval/); + }); + }); + + describe('gateway filter', () => { + const configBundleOpts = (gatewayFilter?: string) => + baseOpts({ + controlBundle: 'promptA', + controlVersion: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + treatmentBundle: 'promptB', + treatmentVersion: 'a1b2c3d4-e5f6-7890-abcd-ef1234567891', + onlineEval: 'quality', + gatewayFilter, + }); + + const targetBasedOpts = (gatewayFilter?: string) => + baseOpts({ + mode: 'target-based', + controlTarget: 'ctrl', + treatmentTarget: 'treat', + controlOnlineEval: 'quality', + treatmentOnlineEval: 'quality', + gatewayFilter, + }); + + it('sets a single target path in config-bundle mode', () => { + const built = buildABTestRequest(configBundleOpts('/orders/*'), deployed); + expect(built.gatewayFilter).toEqual({ targetPaths: ['/orders/*'] }); + }); + + it('sets a single target path in target-based mode', () => { + const built = buildABTestRequest(targetBasedOpts('/orders/*'), deployed); + expect(built.gatewayFilter).toEqual({ targetPaths: ['/orders/*'] }); + }); + + it('trims whitespace around the single path', () => { + const built = buildABTestRequest(targetBasedOpts(' /orders/* '), deployed); + expect(built.gatewayFilter).toEqual({ targetPaths: ['/orders/*'] }); + }); + + it('throws naming the exact paths when more than one is given', () => { + expect(() => buildABTestRequest(targetBasedOpts('/a,/b'), deployed)).toThrow( + /exactly one target path.*Got 2: "\/a", "\/b"/ + ); + expect(() => buildABTestRequest(configBundleOpts('/orders/*, /refunds/*'), deployed)).toThrow( + /Got 2: "\/orders\/\*", "\/refunds\/\*"/ + ); + }); + + it('omits gatewayFilter when not provided in either mode', () => { + expect(buildABTestRequest(configBundleOpts(), deployed).gatewayFilter).toBeUndefined(); + expect(buildABTestRequest(targetBasedOpts(), deployed).gatewayFilter).toBeUndefined(); + }); + }); +}); diff --git a/src/cli/operations/jobs/ab-test/__tests__/format.test.ts b/src/cli/operations/jobs/ab-test/__tests__/format.test.ts new file mode 100644 index 000000000..62b3fc49f --- /dev/null +++ b/src/cli/operations/jobs/ab-test/__tests__/format.test.ts @@ -0,0 +1,47 @@ +import type { ABTestJobRecord } from '../../shared/types'; +import { printABTestDetail } from '../format'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +function baseRecord(overrides: Partial = {}): ABTestJobRecord { + return { + type: 'ab-test', + id: 'abt-123', + arn: 'arn:aws:bedrock-agentcore:us-east-1:1:ab-test/abt-123', + status: 'ACTIVE', + lifecycleStatus: 'RUNNING', + createdAt: '2026-01-01T00:00:00.000Z', + agent: 'MyAgent', + name: 'MyTest', + mode: 'target-based', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:1:gateway/gw-abc', + variants: [ + { name: 'C', weight: 50, targetName: 'ctrl' }, + { name: 'T1', weight: 50, targetName: 'treat' }, + ], + evaluationConfig: { onlineEvaluationConfigArn: 'arn:aws:bedrock-agentcore:us-east-1:1:online-evaluation-config/q' }, + ...overrides, + }; +} + +describe('printABTestDetail — gateway filter', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function capture(record: ABTestJobRecord): string { + const spy = vi.spyOn(console, 'log').mockImplementation(vi.fn()); + printABTestDetail(record); + const output = spy.mock.calls.map(c => c.join(' ')).join('\n'); + return output; + } + + it('renders the filter path when a gatewayFilter is present', () => { + const output = capture(baseRecord({ gatewayFilter: { targetPaths: ['/orders/*'] } })); + expect(output).toContain('Gateway filter: /orders/*'); + }); + + it('renders "none" when no gatewayFilter is present', () => { + const output = capture(baseRecord()); + expect(output).toContain('Gateway filter: none'); + }); +}); diff --git a/src/cli/operations/jobs/ab-test/__tests__/promote.test.ts b/src/cli/operations/jobs/ab-test/__tests__/promote.test.ts new file mode 100644 index 000000000..83b88c193 --- /dev/null +++ b/src/cli/operations/jobs/ab-test/__tests__/promote.test.ts @@ -0,0 +1,434 @@ +import type { ABTestJobRecord } from '../../shared/types'; +import { promoteABTestConfig } from '../promote'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock ConfigIO — vi.hoisted ensures these are available before the hoisted vi.mock runs +const { mockReadProjectSpec, mockWriteProjectSpec, mockReadDeployedState, mockGetConfigurationBundleVersion } = + vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockWriteProjectSpec: vi.fn(), + mockReadDeployedState: vi.fn(), + mockGetConfigurationBundleVersion: vi.fn(), + })); + +vi.mock('../../../../../lib', () => { + class MockConfigIO { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + readDeployedState = mockReadDeployedState; + } + return { ConfigIO: MockConfigIO }; +}); + +vi.mock('../../../../aws/agentcore-config-bundles', () => ({ + getConfigurationBundleVersion: mockGetConfigurationBundleVersion, +})); + +// --------------------------------------------------------------------------- +// Helpers — promote is now RECORD-DRIVEN: it reads the job record's variants, +// not project.abTests[] (which the jobs model never populates). +// --------------------------------------------------------------------------- + +function baseRecord(overrides: Partial): ABTestJobRecord { + return { + type: 'ab-test', + id: 'ab-123', + arn: 'arn:aws:bedrock-agentcore:us-east-1:1:ab-test/ab-123', + status: 'STOPPED', + lifecycleStatus: 'STOPPED', + createdAt: '2026-01-01T00:00:00Z', + agent: 'my-agent', + name: 'myTest', + mode: 'config-bundle', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:1:gateway/my-gw', + variants: [], + evaluationConfig: { onlineEvaluationConfigArn: 'arn:aws:eval:config' }, + ...overrides, + }; +} + +function makeTargetBasedProject() { + return { + name: 'TestProject', + runtimes: [ + { + name: 'my-runtime', + endpoints: { + control: { version: 1 }, + treatment: { version: 2 }, + }, + }, + ], + agentCoreGateways: [ + { + name: 'my-gw', + targets: [ + { + name: 'ctrl-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-runtime', runtimeEndpoint: 'control' }, + }, + { + name: 'treat-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-runtime', runtimeEndpoint: 'treatment' }, + }, + ], + }, + ], + onlineEvalConfigs: [], + configBundles: [], + abTests: [], + }; +} + +// A config-bundle A/B test promotes between two VERSIONS of ONE bundle, so both variants share the +// same bundleArn; only bundleVersion differs. +const BUNDLE_ARN = 'arn:aws:bedrock-agentcore:us-east-1:1:configuration-bundle/promptBundle-abc123'; + +function makeConfigBundleProject() { + return { + name: 'TestProject', + runtimes: [], + agentCoreGateways: [], + onlineEvalConfigs: [], + configBundles: [ + { + name: 'promptBundle', + type: 'ConfigurationBundle', + components: { '{{runtime:r}}': { configuration: { systemPrompt: 'OLD' } } }, + }, + ], + abTests: [], + }; +} + +function makeBundleDeployedState() { + return { + targets: { + default: { + resources: { + configBundles: { + promptBundle: { bundleId: 'promptBundle-abc123', bundleArn: BUNDLE_ARN, versionId: 'v1' }, + }, + }, + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('promoteABTestConfig (record-driven)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockWriteProjectSpec.mockResolvedValue(undefined); + }); + + describe('target-based promote', () => { + it('bumps control endpoint version to treatment version', async () => { + mockReadProjectSpec.mockResolvedValue(makeTargetBasedProject()); + + const record = baseRecord({ + mode: 'target-based', + gatewayName: 'my-gw', + variants: [ + { name: 'C', weight: 50, targetName: 'ctrl-target' }, + { name: 'T1', weight: 50, targetName: 'treat-target' }, + ], + }); + + const result = await promoteABTestConfig(record); + + expect(result.promoted).toBe(true); + expect(result.mode).toBe('target-based'); + expect(result.promotionDetail).toContain('control'); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.runtimes[0].endpoints.control.version).toBe(2); + }); + + it('repoints control to the treatment runtime when variants target different runtimes', async () => { + // Control → runtime-a (endpoint prod), Treatment → runtime-b (endpoint prod). No shared + // runtime to version-bump, so promote clones treatment's httpRuntime onto the control target. + const project = { + name: 'TestProject', + runtimes: [ + { name: 'runtime-a', endpoints: { prod: { version: 1 } } }, + { name: 'runtime-b', endpoints: { prod: { version: 5 } } }, + ], + agentCoreGateways: [ + { + name: 'my-gw', + targets: [ + { + name: 'ctrl-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'runtime-a', runtimeEndpoint: 'prod' }, + }, + { + name: 'treat-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'runtime-b', runtimeEndpoint: 'prod' }, + }, + ], + }, + ], + onlineEvalConfigs: [], + configBundles: [], + abTests: [], + }; + mockReadProjectSpec.mockResolvedValue(project); + + const record = baseRecord({ + mode: 'target-based', + gatewayName: 'my-gw', + variants: [ + { name: 'C', weight: 50, targetName: 'ctrl-target' }, + { name: 'T1', weight: 50, targetName: 'treat-target' }, + ], + }); + + const result = await promoteABTestConfig(record); + + expect(result.promoted).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + const ctrl = written.agentCoreGateways[0].targets.find((t: { name: string }) => t.name === 'ctrl-target'); + expect(ctrl.httpRuntime.runtime).toBe('runtime-b'); + expect(ctrl.httpRuntime.runtimeEndpoint).toBe('prod'); + }); + + it('repoints control when variants use the default (unnamed) endpoint', async () => { + // Neither target names a runtimeEndpoint → no endpoints[name].version to bump → repoint path. + const project = { + name: 'TestProject', + runtimes: [ + { name: 'runtime-a', endpoints: {} }, + { name: 'runtime-b', endpoints: {} }, + ], + agentCoreGateways: [ + { + name: 'my-gw', + targets: [ + { name: 'ctrl-target', targetType: 'httpRuntime', httpRuntime: { runtime: 'runtime-a' } }, + { name: 'treat-target', targetType: 'httpRuntime', httpRuntime: { runtime: 'runtime-b' } }, + ], + }, + ], + onlineEvalConfigs: [], + configBundles: [], + abTests: [], + }; + mockReadProjectSpec.mockResolvedValue(project); + + const record = baseRecord({ + mode: 'target-based', + gatewayName: 'my-gw', + variants: [ + { name: 'C', weight: 50, targetName: 'ctrl-target' }, + { name: 'T1', weight: 50, targetName: 'treat-target' }, + ], + }); + + const result = await promoteABTestConfig(record); + + expect(result.promoted).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + const ctrl = written.agentCoreGateways[0].targets.find((t: { name: string }) => t.name === 'ctrl-target'); + expect(ctrl.httpRuntime.runtime).toBe('runtime-b'); + }); + + it('returns promoted=false when the gateway name is missing from the record', async () => { + mockReadProjectSpec.mockResolvedValue(makeTargetBasedProject()); + const record = baseRecord({ + mode: 'target-based', + gatewayName: undefined, + variants: [ + { name: 'C', weight: 50, targetName: 'ctrl-target' }, + { name: 'T1', weight: 50, targetName: 'treat-target' }, + ], + }); + + const result = await promoteABTestConfig(record); + expect(result.promoted).toBe(false); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + }); + + describe('config-bundle promote', () => { + it('adopts the winning (treatment) version components into the bundle (same bundle, diff version)', async () => { + mockReadProjectSpec.mockResolvedValue(makeConfigBundleProject()); + mockReadDeployedState.mockResolvedValue(makeBundleDeployedState()); + // The service returns the treatment version's components. + mockGetConfigurationBundleVersion.mockResolvedValue({ + components: { '{{runtime:r}}': { configuration: { systemPrompt: 'NEW' } } }, + }); + + const record = baseRecord({ + mode: 'config-bundle', + variants: [ + { name: 'C', weight: 50, bundleArn: BUNDLE_ARN, bundleVersion: 'v1' }, + { name: 'T1', weight: 50, bundleArn: BUNDLE_ARN, bundleVersion: 'v2' }, + ], + }); + + const result = await promoteABTestConfig(record); + + expect(result.promoted).toBe(true); + expect(result.mode).toBe('config-bundle'); + // Fetched the WINNING (treatment) version v2 from the bundle id parsed from the ARN. + expect(mockGetConfigurationBundleVersion).toHaveBeenCalledWith( + expect.objectContaining({ bundleId: 'promptBundle-abc123', versionId: 'v2' }) + ); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + const bundle = written.configBundles.find((b: { name: string }) => b.name === 'promptBundle'); + expect(bundle.components['{{runtime:r}}'].configuration.systemPrompt).toBe('NEW'); + }); + + it('returns promoted=false (error) when control and treatment are DIFFERENT bundles', async () => { + mockReadProjectSpec.mockResolvedValue(makeConfigBundleProject()); + mockReadDeployedState.mockResolvedValue(makeBundleDeployedState()); + + const record = baseRecord({ + mode: 'config-bundle', + variants: [ + { + name: 'C', + weight: 50, + bundleArn: 'arn:aws:bedrock-agentcore:us-east-1:1:configuration-bundle/bundleA', + bundleVersion: 'v1', + }, + { + name: 'T1', + weight: 50, + bundleArn: 'arn:aws:bedrock-agentcore:us-east-1:1:configuration-bundle/bundleB', + bundleVersion: 'v1', + }, + ], + }); + + const result = await promoteABTestConfig(record); + expect(result.promoted).toBe(false); + expect(result.promotionDetail).toContain('different config bundles'); + expect(mockGetConfigurationBundleVersion).not.toHaveBeenCalled(); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('returns promoted=false when the bundle cannot be resolved from deployed state', async () => { + mockReadProjectSpec.mockResolvedValue(makeConfigBundleProject()); + mockReadDeployedState.mockResolvedValue({ targets: { default: { resources: { configBundles: {} } } } }); + + const record = baseRecord({ + mode: 'config-bundle', + variants: [ + { name: 'C', weight: 50, bundleArn: BUNDLE_ARN, bundleVersion: 'v1' }, + { name: 'T1', weight: 50, bundleArn: BUNDLE_ARN, bundleVersion: 'v2' }, + ], + }); + + const result = await promoteABTestConfig(record); + expect(result.promoted).toBe(false); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + }); + + describe('malformed record', () => { + it('returns promoted=false when control/treatment variants are missing', async () => { + mockReadProjectSpec.mockResolvedValue(makeConfigBundleProject()); + const record = baseRecord({ mode: 'config-bundle', variants: [] }); + + const result = await promoteABTestConfig(record); + expect(result.promoted).toBe(false); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + }); + + // BUG-4: promote must validate the winner is applicable BEFORE stopping the test. The dry-run + // path returns the same promoted/detail as a real run but never writes agentcore.json. + describe('dry run (pre-stop preflight)', () => { + it('returns promoted=true without writing for a valid target-based promote', async () => { + mockReadProjectSpec.mockResolvedValue(makeTargetBasedProject()); + const record = baseRecord({ + mode: 'target-based', + gatewayName: 'my-gw', + variants: [ + { name: 'C', weight: 50, targetName: 'ctrl-target' }, + { name: 'T1', weight: 50, targetName: 'treat-target' }, + ], + }); + + const result = await promoteABTestConfig(record, true); + expect(result.promoted).toBe(true); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('returns promoted=false without writing when a target is missing its httpRuntime entirely', async () => { + // No httpRuntime.runtime on either target → nothing to copy from/to → not promotable. + const project = makeTargetBasedProject(); + for (const gw of project.agentCoreGateways) { + for (const t of gw.targets) { + delete (t as { httpRuntime?: unknown }).httpRuntime; + } + } + mockReadProjectSpec.mockResolvedValue(project); + const record = baseRecord({ + mode: 'target-based', + gatewayName: 'my-gw', + variants: [ + { name: 'C', weight: 50, targetName: 'ctrl-target' }, + { name: 'T1', weight: 50, targetName: 'treat-target' }, + ], + }); + + const result = await promoteABTestConfig(record, true); + expect(result.promoted).toBe(false); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('returns promoted=true without writing (or fetching) for a valid config-bundle promote', async () => { + mockReadProjectSpec.mockResolvedValue(makeConfigBundleProject()); + mockReadDeployedState.mockResolvedValue(makeBundleDeployedState()); + const record = baseRecord({ + mode: 'config-bundle', + variants: [ + { name: 'C', weight: 50, bundleArn: BUNDLE_ARN, bundleVersion: 'v1' }, + { name: 'T1', weight: 50, bundleArn: BUNDLE_ARN, bundleVersion: 'v2' }, + ], + }); + + const result = await promoteABTestConfig(record, true); + expect(result.promoted).toBe(true); + // dry-run must not touch the service or write the spec + expect(mockGetConfigurationBundleVersion).not.toHaveBeenCalled(); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('returns promoted=false without writing when control/treatment are different bundles (dry-run)', async () => { + mockReadProjectSpec.mockResolvedValue(makeConfigBundleProject()); + mockReadDeployedState.mockResolvedValue(makeBundleDeployedState()); + const record = baseRecord({ + mode: 'config-bundle', + variants: [ + { + name: 'C', + weight: 50, + bundleArn: 'arn:aws:bedrock-agentcore:us-east-1:1:configuration-bundle/bundleA', + bundleVersion: 'v1', + }, + { + name: 'T1', + weight: 50, + bundleArn: 'arn:aws:bedrock-agentcore:us-east-1:1:configuration-bundle/bundleB', + bundleVersion: 'v1', + }, + ], + }); + + const result = await promoteABTestConfig(record, true); + expect(result.promoted).toBe(false); + expect(result.promotionDetail).toContain('different config bundles'); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/cli/operations/jobs/ab-test/build-options.ts b/src/cli/operations/jobs/ab-test/build-options.ts new file mode 100644 index 000000000..651f68d65 --- /dev/null +++ b/src/cli/operations/jobs/ab-test/build-options.ts @@ -0,0 +1,158 @@ +/** + * Build the CreateABTest request (variants + eval config + filters) and the persisted + * variant summaries from the engine-facing StartABTestJobOptions. ARN/name resolution runs + * against deployed state so a user can pass bundle/target/eval NAMES on the command line. + */ +import { ResourceNotFoundError, ValidationError } from '../../../../lib'; +import type { DeployedResourceState } from '../../../../schema'; +import type { ABTestEvaluationConfig, ABTestVariant, GatewayFilter } from '../../../aws/agentcore-ab-tests'; +import type { ABTestVariantSummary, StartABTestJobOptions } from '../shared/types'; +import { resolveConfigBundleArn, resolveConfigBundleVersion, resolveOnlineEvalArn } from './resolve'; + +export interface BuiltABTestRequest { + variants: ABTestVariant[]; + evaluationConfig: ABTestEvaluationConfig; + gatewayFilter?: GatewayFilter; + /** Resolved summaries persisted on the record for display. */ + variantSummaries: ABTestVariantSummary[]; +} + +/** Resolve a gateway-target name. The L3 CDK construct deploys targets by their spec name as-is. */ +function resolveTargetName(targetName: string): string { + return targetName; +} + +/** + * Assemble the AB-test create request from start options. Throws ValidationError when a + * mode's required inputs are missing (caught by the handler → `{ success: false }`). + */ +export function buildABTestRequest( + opts: StartABTestJobOptions, + deployedResources?: DeployedResourceState +): BuiltABTestRequest { + const variants: ABTestVariant[] = []; + const variantSummaries: ABTestVariantSummary[] = []; + let evaluationConfig: ABTestEvaluationConfig; + let gatewayFilter: GatewayFilter | undefined; + + if (opts.mode === 'config-bundle') { + if (!opts.controlBundle || !opts.controlVersion || !opts.treatmentBundle || !opts.treatmentVersion) { + throw new ValidationError('config-bundle A/B test requires control and treatment bundle names and versions.'); + } + if (!opts.onlineEval) { + throw new ValidationError('config-bundle A/B test requires an online-eval config.'); + } + + const controlArn = resolveConfigBundleArn(opts.controlBundle, deployedResources); + const controlVer = resolveConfigBundleVersion(opts.controlBundle, opts.controlVersion, deployedResources); + const treatmentArn = resolveConfigBundleArn(opts.treatmentBundle, deployedResources); + const treatmentVer = resolveConfigBundleVersion(opts.treatmentBundle, opts.treatmentVersion, deployedResources); + + if (!controlArn) { + throw new ResourceNotFoundError( + `Config bundle "${opts.controlBundle}" is not deployed. Run \`agentcore add config-bundle\` and \`agentcore deploy\` first.` + ); + } + if (!controlVer) { + throw new ResourceNotFoundError( + `Could not resolve version "${opts.controlVersion}" for config bundle "${opts.controlBundle}". Use LATEST or a version UUID (e.g. a1b2c3d4-e5f6-7890-abcd-ef1234567890).` + ); + } + if (!treatmentArn) { + throw new ResourceNotFoundError( + `Config bundle "${opts.treatmentBundle}" is not deployed. Run \`agentcore add config-bundle\` and \`agentcore deploy\` first.` + ); + } + if (!treatmentVer) { + throw new ResourceNotFoundError( + `Could not resolve version "${opts.treatmentVersion}" for config bundle "${opts.treatmentBundle}". Use LATEST or a version UUID (e.g. a1b2c3d4-e5f6-7890-abcd-ef1234567890).` + ); + } + + variants.push( + { + name: 'C', + weight: opts.controlWeight, + variantConfiguration: { configurationBundle: { bundleArn: controlArn, bundleVersion: controlVer } }, + }, + { + name: 'T1', + weight: opts.treatmentWeight, + variantConfiguration: { configurationBundle: { bundleArn: treatmentArn, bundleVersion: treatmentVer } }, + } + ); + variantSummaries.push( + { name: 'C', weight: opts.controlWeight, bundleArn: controlArn, bundleVersion: controlVer }, + { name: 'T1', weight: opts.treatmentWeight, bundleArn: treatmentArn, bundleVersion: treatmentVer } + ); + + const onlineEvalArn = resolveOnlineEvalArn(opts.onlineEval, deployedResources); + if (!onlineEvalArn) { + throw new ResourceNotFoundError( + `Online-eval config "${opts.onlineEval}" is not deployed. Run \`agentcore add online-eval\` and \`agentcore deploy\` first.` + ); + } + evaluationConfig = { onlineEvaluationConfigArn: onlineEvalArn }; + } else { + // target-based + if (!opts.controlTarget || !opts.treatmentTarget) { + throw new ValidationError('target-based A/B test requires control and treatment target names.'); + } + + const controlName = resolveTargetName(opts.controlTarget); + const treatmentName = resolveTargetName(opts.treatmentTarget); + + variants.push( + { name: 'C', weight: opts.controlWeight, variantConfiguration: { target: { name: controlName } } }, + { name: 'T1', weight: opts.treatmentWeight, variantConfiguration: { target: { name: treatmentName } } } + ); + variantSummaries.push( + { name: 'C', weight: opts.controlWeight, targetName: controlName }, + { name: 'T1', weight: opts.treatmentWeight, targetName: treatmentName } + ); + + // Target-based mode always requires per-variant eval configs (each scoped to its endpoint). + if (!opts.controlOnlineEval || !opts.treatmentOnlineEval) { + throw new ValidationError( + 'target-based A/B test requires --control-online-eval and --treatment-online-eval (one per endpoint).' + ); + } + const controlEvalArn = resolveOnlineEvalArn(opts.controlOnlineEval, deployedResources); + if (!controlEvalArn) { + throw new ResourceNotFoundError( + `Online-eval config "${opts.controlOnlineEval}" (--control-online-eval) is not deployed. Run \`agentcore add online-eval\` and \`agentcore deploy\` first.` + ); + } + const treatmentEvalArn = resolveOnlineEvalArn(opts.treatmentOnlineEval, deployedResources); + if (!treatmentEvalArn) { + throw new ResourceNotFoundError( + `Online-eval config "${opts.treatmentOnlineEval}" (--treatment-online-eval) is not deployed. Run \`agentcore add online-eval\` and \`agentcore deploy\` first.` + ); + } + evaluationConfig = { + perVariantOnlineEvaluationConfig: [ + { name: 'C', onlineEvaluationConfigArn: controlEvalArn }, + { name: 'T1', onlineEvaluationConfigArn: treatmentEvalArn }, + ], + }; + } + + // Gateway filter applies to BOTH modes. The service allows exactly one target path; reject more. + if (opts.gatewayFilter) { + const targetPaths = opts.gatewayFilter + .split(',') + .map(s => s.trim()) + .filter(Boolean); + if (targetPaths.length > 1) { + throw new ValidationError( + `--gateway-filter accepts exactly one target path pattern (the service allows a single path). ` + + `Got ${targetPaths.length}: ${targetPaths.map(p => `"${p}"`).join(', ')}.` + ); + } + if (targetPaths.length === 1) { + gatewayFilter = { targetPaths: [targetPaths[0]!] }; + } + } + + return { variants, evaluationConfig, gatewayFilter, variantSummaries }; +} diff --git a/src/cli/operations/jobs/ab-test/format.ts b/src/cli/operations/jobs/ab-test/format.ts new file mode 100644 index 000000000..d11f66370 --- /dev/null +++ b/src/cli/operations/jobs/ab-test/format.ts @@ -0,0 +1,85 @@ +/** Presentation helpers for A/B-test job CLI output (history table + detail view). */ +import { dnsSuffix } from '../../../aws/partition'; +import { formatJobDate } from '../shared/format'; +import type { ABTestJobRecord } from '../shared/types'; + +/** + * Derive the gateway invocation URL from the stored gateway ARN. + * Target-based: `https://{gateway}/{control-target-name}/invocations`. + * Config-bundle: `https://{gateway}/{agent-name}/invocations`. + */ +export function getInvocationUrl(record: ABTestJobRecord): string | undefined { + const parts = record.gatewayArn.split(':'); + const region = parts[3]; + const gatewayId = parts[5]?.split('/')[1]; + if (!region || !gatewayId) return undefined; + const baseUrl = `https://${gatewayId}.gateway.bedrock-agentcore.${region}.${dnsSuffix(region)}`; + if (record.mode === 'target-based') { + const targetName = record.variants[0]?.targetName; + return targetName ? `${baseUrl}/${targetName}/invocations` : undefined; + } + return record.agent ? `${baseUrl}/${record.agent}/invocations` : undefined; +} + +export function printABTestHistory(records: ABTestJobRecord[]): void { + if (records.length === 0) { + console.log('No A/B test jobs found. Run `agentcore run ab-test` to create one.'); + return; + } + console.log( + `\n${'Date'.padEnd(22)} ${'Execution'.padEnd(12)} ${'Lifecycle'.padEnd(12)} ${'Name'.padEnd(24)} ${'ID'}` + ); + console.log('─'.repeat(100)); + for (const r of records) { + console.log( + `${formatJobDate(r.createdAt).padEnd(22)} ${r.status.padEnd(12)} ${r.lifecycleStatus.padEnd(12)} ${r.name.padEnd(24)} ${r.id}` + ); + } + console.log(''); +} + +export function printABTestDetail(record: ABTestJobRecord): void { + console.log(`\nA/B test: ${record.id}`); + console.log(`Name: ${record.name}`); + console.log(`Mode: ${record.mode}`); + console.log(`Execution status: ${record.status}`); + console.log(`Lifecycle status: ${record.lifecycleStatus}`); + console.log(`Gateway: ${record.gatewayArn}`); + console.log(`Gateway filter: ${record.gatewayFilter?.targetPaths?.[0] ?? 'none'}`); + const invocationUrl = getInvocationUrl(record); + if (invocationUrl) console.log(`Invocation URL: ${invocationUrl}`); + console.log(`Started: ${formatJobDate(record.createdAt)}`); + if (record.completedAt) console.log(`Stopped: ${formatJobDate(record.completedAt)}`); + if (record.maxDurationExpiresAt) console.log(`Max duration expires: ${formatJobDate(record.maxDurationExpiresAt)}`); + + console.log('\nVariants:'); + for (const v of record.variants) { + const detail = v.bundleArn + ? `bundle ${v.bundleArn} @ ${v.bundleVersion}` + : v.targetName + ? `target ${v.targetName}` + : '(unspecified)'; + console.log(` ${v.name} (weight ${v.weight}): ${detail}`); + } + + const metrics = record.results?.evaluatorMetrics; + if (metrics?.length) { + console.log('\nResults:'); + for (const m of metrics) { + console.log(` ${m.evaluatorArn}`); + console.log(` C (n=${m.controlStats.sampleSize}): mean ${m.controlStats.mean.toFixed(3)}`); + for (const vr of m.variantResults) { + const change = + vr.percentChange != null ? ` (${vr.percentChange > 0 ? '+' : ''}${vr.percentChange.toFixed(1)}%)` : ''; + const sig = vr.isSignificant ? ' *significant*' : ''; + console.log(` ${vr.treatmentName} (n=${vr.sampleSize}): mean ${vr.mean.toFixed(3)}${change}${sig}`); + } + } + } else if (record.failureReason) { + console.log(`\nFailure: ${record.failureReason}`); + } else { + console.log('\nResults not yet available.'); + } + if (record.logFilePath) console.log(`\nLog: ${record.logFilePath}`); + console.log(''); +} diff --git a/src/cli/operations/jobs/ab-test/handler.ts b/src/cli/operations/jobs/ab-test/handler.ts new file mode 100644 index 000000000..7c97e5ce4 --- /dev/null +++ b/src/cli/operations/jobs/ab-test/handler.ts @@ -0,0 +1,466 @@ +/** + * AB-test job handler — composes Startable, Refreshable, Stoppable, Pausable, Promotable, Archivable. + * + * - create(): resolve region + gateway ARN (gateway must already be deployed), build variants + + * eval config, create (or reuse) the execution role, make ONE CreateABTest call + * (with AccessDenied retry while IAM propagates), persist the record. The role is + * cleaned up if the create call ultimately fails. + * - refresh(): GET latest state; map 404 → NOT_FOUND. Store executionStatus in `status` and the + * lifecycle `status` in `lifecycleStatus`; carry results / failureReason / expiry. + * - stop/pause/resume(): UpdateABTest executionStatus = STOPPED / PAUSED / RUNNING. + * - promote(): wait until RUNNING, stop, then apply the winning variant to agentcore.json. + * - archive(): stop → poll STOPPED → DeleteABTest → delete the role if the CLI created it. + */ +import { ConfigIO, JobNotFoundError, ResourceNotFoundError, toError } from '../../../../lib'; +import type { Result } from '../../../../lib/result'; +import type { DeployedResourceState, DeployedState } from '../../../../schema'; +import { getCredentialProvider } from '../../../aws/account'; +import { createABTest, deleteABTest, getABTest, listABTests, updateABTest } from '../../../aws/agentcore-ab-tests'; +import { getGatewayDetail, getOnlineEvaluationConfig } from '../../../aws/agentcore-control'; +import { detectRegion } from '../../../aws/region'; +import { getErrorMessage } from '../../../errors'; +import { ExecLogger } from '../../../logging/exec-logger'; +import { NOT_FOUND_STATUS } from '../shared/constants'; +import { regionFromArn, resolveJobRegion } from '../shared/region'; +import type { ABTestHandler, ABTestJobRecord, DebugCheckResult, StartABTestJobOptions } from '../shared/types'; +import { buildABTestRequest } from './build-options'; +import { promoteABTestConfig } from './promote'; +import { deleteABTestRole, getOrCreateABTestRole, resolveGatewayArn } from './resolve'; +import { CloudWatchLogsClient, FilterLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs'; + +/** AB-test create retries while the freshly-created IAM role propagates (gateway/eval AccessDenied). */ +const MAX_CREATE_RETRIES = 5; +const BASE_RETRY_DELAY_MS = 5_000; + +/** Merge per-target deployed resources into one view (AB tests resolve names across all targets). */ +function mergeDeployedResources(deployedState: DeployedState): DeployedResourceState { + const merged: DeployedResourceState = {}; + for (const target of Object.values(deployedState.targets)) { + const r = target.resources; + if (!r) continue; + Object.assign(merged, { + mcp: { ...merged.mcp, ...r.mcp }, + gateways: { ...merged.gateways, ...r.gateways }, + configBundles: { ...merged.configBundles, ...r.configBundles }, + onlineEvalConfigs: { ...merged.onlineEvalConfigs, ...r.onlineEvalConfigs }, + }); + } + return merged; +} + +/** Poll executionStatus until STOPPED (best-effort, bounded). */ +async function pollUntilStopped(region: string, abTestId: string, attempts = 20, delayMs = 3_000): Promise { + for (let i = 0; i < attempts; i++) { + try { + const test = await getABTest({ region, abTestId }); + if (test.executionStatus === 'STOPPED') return true; + } catch (err) { + if (err instanceof JobNotFoundError) return true; // already gone + // transient — keep polling + } + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + return false; +} + +/** + * Wait until the test reaches RUNNING (a just-created test may still be enabling), then stop it. + * Throws if it never reaches RUNNING — promotion of a never-started test is not meaningful. + */ +async function waitForRunningThenStop( + region: string, + abTestId: string, + attempts = 12, + delayMs = 10_000 +): Promise { + let status: string | undefined; + for (let i = 0; i < attempts; i++) { + const current = await getABTest({ region, abTestId }); + status = current.executionStatus; + if (status === 'RUNNING') break; + if (status === 'STOPPED') return; // already stopped — nothing more to do + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + if (status !== 'RUNNING') { + throw new Error(`A/B test "${abTestId}" did not reach RUNNING (current: ${status}); cannot promote.`); + } + await updateABTest({ region, abTestId, executionStatus: 'STOPPED' }); +} + +export const abTestHandler: ABTestHandler = { + async create(opts: StartABTestJobOptions, configIO: ConfigIO): Promise> { + let logger: ExecLogger | undefined; + try { + logger = new ExecLogger({ command: 'ab-test' }); + } catch { + // non-fatal + } + + let region = ''; + let roleArn: string | undefined; + let roleCreatedByCli = false; + try { + logger?.startStep('Load project config'); + const [projectSpec, deployedState, awsTargets] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readDeployedState(), + configIO.resolveAWSDeploymentTargets(), + ]); + region = await resolveJobRegion(opts.region, awsTargets); + const deployedResources = mergeDeployedResources(deployedState); + logger?.endStep('success'); + + // Gateway must already be deployed — we never auto-create it. + logger?.startStep('Resolve gateway'); + const gatewayArn = resolveGatewayArn(opts.gateway, deployedResources); + if (!gatewayArn || !gatewayArn.startsWith('arn:') || gatewayArn.split(':').length < 6) { + const err = new ResourceNotFoundError( + `Gateway "${opts.gateway}" is not deployed. Run \`agentcore add gateway\` and \`agentcore deploy\` first.` + ); + logger?.endStep('error', err.message); + logger?.finalize(false); + return { success: false, error: err }; + } + logger?.log(`Gateway ARN: ${gatewayArn}`); + logger?.endStep('success'); + + // Build variants + eval config (throws ValidationError on missing mode inputs). + const built = buildABTestRequest(opts, deployedResources); + + // Resolve (or create) the execution role. + logger?.startStep('Resolve execution role'); + if (opts.roleArn) { + roleArn = opts.roleArn; + } else { + opts.onProgress?.('role', 'Creating execution role (waiting for IAM propagation)...'); + roleArn = await getOrCreateABTestRole({ + region, + projectName: projectSpec.name, + testName: opts.name, + gatewayArn, + }); + roleCreatedByCli = true; + } + logger?.log(`Role ARN: ${roleArn}`); + logger?.endStep('success'); + + // ONE create call, with AccessDenied retry while IAM propagates. + logger?.startStep('Create A/B test'); + opts.onProgress?.('starting', `Creating A/B test "${opts.name}"...`); + const createOptions = { + region, + name: `${projectSpec.name}_${opts.name}`, + description: opts.description, + gatewayArn, + roleArn, + variants: built.variants, + evaluationConfig: built.evaluationConfig, + gatewayFilter: built.gatewayFilter, + enableOnCreate: opts.enableOnCreate, + }; + + let createResult; + for (let attempt = 0; attempt < MAX_CREATE_RETRIES; attempt++) { + try { + createResult = await createABTest(createOptions); + break; + } catch (err: unknown) { + const errCode = (err as { name?: string }).name; + const errStatus = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode; + const msg = err instanceof Error ? err.message : String(err); + const isRetryable = + errCode === 'AccessDeniedException' || + errStatus === 403 || + msg.includes('Access denied') || + msg.includes('Gateway validation error'); + if (isRetryable && attempt < MAX_CREATE_RETRIES - 1) { + const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt); + opts.onProgress?.('retry', `Access not yet propagated; retrying (attempt ${attempt + 2})...`); + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + throw err; + } + } + if (!createResult) { + throw new Error('A/B test creation failed after retries.'); + } + logger?.log(`Response: ${JSON.stringify(createResult, null, 2)}`); + logger?.endStep('success'); + opts.onProgress?.('started', `A/B test created: ${createResult.abTestId} (${createResult.executionStatus})`); + logger?.finalize(true); + + const record: ABTestJobRecord = { + type: 'ab-test', + id: createResult.abTestId, + arn: createResult.abTestArn, + status: createResult.status, + lifecycleStatus: createResult.executionStatus, + createdAt: createResult.createdAt ?? new Date().toISOString(), + agent: opts.agent ?? opts.runtime ?? opts.name, + logFilePath: logger?.logFilePath, + name: opts.name, + mode: opts.mode, + gatewayArn, + gatewayName: opts.gateway, + roleArn, + roleCreatedByCli, + variants: built.variantSummaries, + evaluationConfig: built.evaluationConfig, + gatewayFilter: built.gatewayFilter, + }; + return { success: true, record }; + } catch (err) { + // Clean up an auto-created role so a failed create doesn't orphan IAM resources. + if (roleCreatedByCli && roleArn && region) { + try { + await deleteABTestRole(region, roleArn); + } catch { + // best-effort + } + } + logger?.finalize(false); + return { success: false, error: toError(err) }; + } + }, + + async refresh(record: ABTestJobRecord): Promise> { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + let response; + try { + response = await getABTest({ region, abTestId: record.id }); + } catch (err) { + if (err instanceof JobNotFoundError) { + return { success: true, record: { ...record, status: NOT_FOUND_STATUS, lifecycleStatus: NOT_FOUND_STATUS } }; + } + return { success: false, error: toError(err) }; + } + + const failureReason = response.failureReason ?? response.errorDetails?.join('; ') ?? record.failureReason; + + return { + success: true, + record: { + ...record, + status: response.status, + lifecycleStatus: response.executionStatus, + completedAt: response.stoppedAt ?? record.completedAt, + maxDurationExpiresAt: response.maxDurationExpiresAt ?? record.maxDurationExpiresAt, + results: response.results ?? record.results, + failureReason, + }, + }; + }, + + async stop(record: ABTestJobRecord): Promise { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + try { + await updateABTest({ region, abTestId: record.id, executionStatus: 'STOPPED' }); + return { success: true }; + } catch (err) { + return { success: false, error: toError(err) }; + } + }, + + async pause(record: ABTestJobRecord): Promise> { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + try { + await updateABTest({ region, abTestId: record.id, executionStatus: 'PAUSED' }); + return { success: true, record: { ...record, lifecycleStatus: 'PAUSED' } }; + } catch (err) { + return { success: false, error: toError(err) }; + } + }, + + async resume(record: ABTestJobRecord): Promise> { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + try { + await updateABTest({ region, abTestId: record.id, executionStatus: 'RUNNING' }); + return { success: true, record: { ...record, lifecycleStatus: 'RUNNING' } }; + } catch (err) { + return { success: false, error: toError(err) }; + } + }, + + async promote(record: ABTestJobRecord, _configIO: ConfigIO): Promise> { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + try { + // Validate the winning variant is applicable to agentcore.json BEFORE stopping the test + // (dry run, no writes). This avoids the trap where promote stops a running test and only + // then discovers it can't apply the winner — e.g. target-based with no named runtime + // endpoint — leaving the test stopped but config unchanged. + const preflight = await promoteABTestConfig(record, true); + if (!preflight.promoted) { + return { + success: false, + error: new Error( + `Cannot promote A/B test "${record.id}": ${preflight.promotionDetail} ` + + `The test was left running (not stopped).` + ), + }; + } + + // Promotion stops the test first (running tests apply continuously), then mutates config. + await waitForRunningThenStop(region, record.id); + const promotion = await promoteABTestConfig(record); + if (!promotion.promoted) { + // The test was stopped, but applying the winning variant to agentcore.json failed. + // Surface the failure so the user knows config wasn't updated (test is already STOPPED). + return { + success: false, + error: new Error( + `A/B test "${record.id}" was stopped, but the winning variant could not be applied to agentcore.json: ${promotion.promotionDetail}` + ), + }; + } + return { success: true, record: { ...record, lifecycleStatus: 'STOPPED' } }; + } catch (err) { + return { success: false, error: toError(err) }; + } + }, + + async archive(record: ABTestJobRecord): Promise { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + try { + // Running tests can't be deleted — stop and wait for STOPPED first (best-effort). + try { + await updateABTest({ region, abTestId: record.id, executionStatus: 'STOPPED' }); + await pollUntilStopped(region, record.id); + } catch (err) { + if (!(err instanceof JobNotFoundError)) { + // already-stopped / transient — proceed to delete + } + } + const deleteResult = await deleteABTest({ region, abTestId: record.id }); + if (!deleteResult.success && !deleteResult.error?.includes('404')) { + return { success: false, error: new Error(deleteResult.error ?? 'Failed to delete A/B test.') }; + } + if (record.roleCreatedByCli && record.roleArn) { + try { + const allTests = await listABTests({ region, maxResults: 100 }); + const activeTests = allTests.abTests.filter( + t => t.abTestId !== record.id && !['STOPPED', 'CREATE_FAILED', 'DELETE_FAILED'].includes(t.status) + ); + let roleInUse = false; + for (const test of activeTests) { + const detail = await getABTest({ region, abTestId: test.abTestId }); + if (detail.roleArn === record.roleArn) { + roleInUse = true; + break; + } + } + if (!roleInUse) { + await deleteABTestRole(region, record.roleArn); + } + } catch { + // Best-effort: if we can't verify, skip deletion (safe side — don't orphan other tests) + } + } + return { success: true }; + } catch (err) { + if (err instanceof JobNotFoundError) { + return { success: true }; + } + return { success: false, error: toError(err) }; + } + }, + + async debug(record: ABTestJobRecord): Promise> { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + const results: DebugCheckResult[] = []; + + // 1. Fetch fresh state from the API + let test; + try { + test = await getABTest({ region, abTestId: record.id }); + results.push({ + label: 'AB Test Status', + status: test.status === 'ACTIVE' && test.executionStatus === 'RUNNING' ? 'pass' : 'warn', + detail: `${test.status} / ${test.executionStatus}`, + }); + } catch (err) { + results.push({ label: 'AB Test Status', status: 'fail', detail: getErrorMessage(err) }); + return { success: true, checks: results }; + } + + // 2. Role + results.push({ + label: 'AB Test Role', + status: test.roleArn ? 'pass' : 'warn', + detail: test.roleArn ?? 'No role ARN', + }); + + // 3. Online Eval Config(s) + const evalConfigArns: { name: string; arn: string }[] = + 'perVariantOnlineEvaluationConfig' in test.evaluationConfig + ? test.evaluationConfig.perVariantOnlineEvaluationConfig.map(v => ({ + name: v.name, + arn: v.onlineEvaluationConfigArn, + })) + : [{ name: '', arn: test.evaluationConfig.onlineEvaluationConfigArn }]; + + for (const { name: variantName, arn: evalArn } of evalConfigArns) { + const evalConfigId = evalArn.split('/').pop() ?? evalArn; + const labelSuffix = variantName ? ` (${variantName})` : ''; + try { + const evalConfig = await getOnlineEvaluationConfig({ region, configId: evalConfigId }); + results.push({ + label: `Online Eval Config${labelSuffix}`, + status: evalConfig.executionStatus === 'ENABLED' ? 'pass' : 'fail', + detail: `${evalConfig.configName} — ${evalConfig.executionStatus}`, + }); + } catch (err) { + results.push({ label: `Online Eval Config${labelSuffix}`, status: 'fail', detail: getErrorMessage(err) }); + } + } + + // 4. Gateway role + const gatewayId = test.gatewayArn.split('/').pop() ?? ''; + try { + const gateway = await getGatewayDetail({ region, gatewayId }); + results.push({ + label: 'Gateway Role', + status: gateway.roleArn ? 'pass' : 'warn', + detail: gateway.roleArn ?? 'No role ARN', + }); + } catch (err) { + results.push({ label: 'Gateway Role', status: 'fail', detail: getErrorMessage(err) }); + } + + // 5. Runtime experiment spans (last 2h) + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + const logsClient = new CloudWatchLogsClient({ region, credentials: getCredentialProvider() }); + const variantNames = test.variants.map(v => v.name); + + try { + // Check for spans tagged with the AB test ARN per variant + for (const name of variantNames) { + try { + const response = await logsClient.send( + new FilterLogEventsCommand({ + logGroupName: 'aws/spans', + startTime: twoHoursAgo, + filterPattern: `"${test.abTestArn}" "${name}"`, + limit: 5, + }) + ); + const count = response.events?.length ?? 0; + results.push({ + label: `Experiment Spans — ${name} (2h)`, + status: count > 0 ? 'pass' : 'warn', + detail: + count > 0 + ? `${count}+ spans with experiment metadata` + : 'No spans found — traffic may not be reaching this variant', + }); + } catch (err) { + results.push({ label: `Experiment Spans — ${name}`, status: 'warn', detail: getErrorMessage(err) }); + } + } + } catch (err) { + results.push({ label: 'Experiment Spans', status: 'warn', detail: getErrorMessage(err) }); + } + + return { success: true, checks: results }; + }, +}; diff --git a/src/cli/operations/jobs/ab-test/promote.ts b/src/cli/operations/jobs/ab-test/promote.ts new file mode 100644 index 000000000..a70aea72b --- /dev/null +++ b/src/cli/operations/jobs/ab-test/promote.ts @@ -0,0 +1,207 @@ +import { ConfigIO } from '../../../../lib'; +import { getConfigurationBundleVersion } from '../../../aws/agentcore-config-bundles'; +import { regionFromArn } from '../shared/region'; +import type { ABTestJobRecord, ABTestVariantSummary } from '../shared/types'; + +/** Extract the bundle id (the ARN's resource suffix) from a configuration-bundle ARN. */ +function bundleIdFromArn(arn: string): string | undefined { + const id = arn.split('/').pop(); + return id && id.length > 0 ? id : undefined; +} + +export interface PromoteABTestResult { + promoted: boolean; + mode?: string; + promotionDetail: string; +} + +/** Reverse-resolve a deployed config-bundle ARN to its spec name (the key in configBundles[]). */ +function bundleNameFromArn( + deployedState: { targets: Record } }> }, + bundleArn: string +): string | undefined { + for (const target of Object.values(deployedState.targets)) { + const bundles = target.resources?.configBundles; + if (!bundles) continue; + for (const [name, entry] of Object.entries(bundles)) { + if (entry.bundleArn === bundleArn) return name; + } + } + return undefined; +} + +/** + * Apply A/B test promotion to agentcore.json, sourcing the winning (treatment / T1) variant + * from the job record's persisted `variants` — NOT from project.abTests[] (which the fire-and-forget + * jobs model never populates). Does NOT stop the test — the handler does that first. + * + * - config-bundle mode: control and treatment must be two VERSIONS of the SAME bundle (different + * bundleArn → rejected). Adopts the treatment version's components into the bundle (fetched from + * the service); a later deploy version-bumps it, with lineage handled server-side. + * - target-based mode: if both variants are named endpoints of the same runtime, bump the control + * endpoint's version to the treatment endpoint's version (control keeps its identity). Otherwise + * (different runtimes, or the default unnamed endpoint) repoint the control target at whatever the + * treatment target serves by cloning its httpRuntime. Either way control ends up serving treatment. + * + * @param dryRun When true, performs the exact same resolution/validation but does NOT write + * agentcore.json. Lets the caller verify the winner is applicable BEFORE stopping the test, so a + * non-promotable test (e.g. target-based with a missing control/treatment target) fails fast + * without first stopping the running test. The `promoted` flag + `promotionDetail` are identical + * to a real run. + */ +export async function promoteABTestConfig(record: ABTestJobRecord, dryRun = false): Promise { + const configIO = new ConfigIO(); + const project = await configIO.readProjectSpec(); + const mode = record.mode; + + const control = record.variants.find((v: ABTestVariantSummary) => v.name === 'C'); + const treatment = record.variants.find((v: ABTestVariantSummary) => v.name === 'T1'); + if (!control || !treatment) { + return { + promoted: false, + mode, + promotionDetail: 'A/B test record is missing control (C) or treatment (T1) variant.', + }; + } + + if (mode === 'target-based') { + if (!record.gatewayName) { + return { + promoted: false, + mode, + promotionDetail: 'A/B test record is missing the gateway name; cannot locate targets.', + }; + } + const gateway = (project.agentCoreGateways ?? []).find(g => g.name === record.gatewayName); + if (!gateway?.targets) { + return { promoted: false, mode, promotionDetail: `Gateway "${record.gatewayName}" not found in agentcore.json.` }; + } + const controlTarget = gateway.targets.find(t => t.name === control.targetName); + const treatmentTarget = gateway.targets.find(t => t.name === treatment.targetName); + // Control must exist (we write to it); treatment must have a runtime to copy from. These are the + // only genuinely unpromotable cases — a missing target means there is nothing to apply. + if (!controlTarget?.httpRuntime?.runtime || !treatmentTarget?.httpRuntime?.runtime) { + return { + promoted: false, + mode, + promotionDetail: 'Could not resolve control/treatment runtime targets for promotion.', + }; + } + + // Fast path: both variants are named endpoints of the SAME runtime, differing only by version. + // Promote by bumping control's endpoint version to treatment's — control keeps its identity. + const sameRuntime = controlTarget.httpRuntime.runtime === treatmentTarget.httpRuntime.runtime; + const controlEpName = controlTarget.httpRuntime.runtimeEndpoint; + const treatmentEpName = treatmentTarget.httpRuntime.runtimeEndpoint; + if (sameRuntime && controlEpName && treatmentEpName) { + const runtime = project.runtimes.find(r => r.name === controlTarget.httpRuntime!.runtime); + const controlEp = runtime?.endpoints?.[controlEpName]; + const treatmentEp = runtime?.endpoints?.[treatmentEpName]; + if (controlEp && treatmentEp) { + if (!dryRun) { + controlEp.version = treatmentEp.version; + await configIO.writeProjectSpec(project); + } + return { + promoted: true, + mode, + promotionDetail: `Control endpoint "${controlEpName}" updated to version ${treatmentEp.version} (from treatment "${treatmentEpName}").`, + }; + } + } + + // General path: control and treatment point at different runtimes, or use the default + // (unnamed) endpoint, so there is no single version field to bump. Repoint the control target + // at exactly what treatment serves by cloning its httpRuntime block. + if (!dryRun) { + controlTarget.httpRuntime = structuredClone(treatmentTarget.httpRuntime); + await configIO.writeProjectSpec(project); + } + const treatmentRef = treatmentEpName + ? `${treatmentTarget.httpRuntime.runtime} (endpoint "${treatmentEpName}")` + : treatmentTarget.httpRuntime.runtime; + return { + promoted: true, + mode, + promotionDetail: `Control target "${controlTarget.name}" repointed to treatment runtime ${treatmentRef}.`, + }; + } + + // config-bundle mode: the control bundle adopts the WINNING (treatment) version's components. + if (!control.bundleArn || !treatment.bundleArn) { + return { promoted: false, mode, promotionDetail: 'A/B test record is missing control/treatment bundle ARNs.' }; + } + + // Promote is only coherent when control and treatment are two VERSIONS of the SAME bundle. + // A ConfigurationBundle version bump is parented to the same bundle's prior version (the service + // tracks lineage per bundle), so "promote treatment into control" means adopting the treatment + // VERSION's components into that one bundle. Two different bundles have independent lineages and + // cannot be promoted into one another — reject that up front. + if (control.bundleArn !== treatment.bundleArn) { + return { + promoted: false, + mode, + promotionDetail: + 'Cannot promote: control and treatment reference different config bundles. ' + + 'A config-bundle A/B test can only promote between two versions of the SAME bundle.', + }; + } + + if (!treatment.bundleVersion) { + return { + promoted: false, + mode, + promotionDetail: 'A/B test record is missing the treatment bundle version; cannot promote.', + }; + } + + let controlName: string | undefined; + try { + const deployedState = await configIO.readDeployedState(); + controlName = bundleNameFromArn(deployedState, control.bundleArn); + } catch { + // deployed state unavailable + } + if (!controlName) { + return { + promoted: false, + mode, + promotionDetail: 'Could not resolve the config bundle from deployed state (deploy the bundle first).', + }; + } + + const controlBundle = (project.configBundles ?? []).find(b => b.name === controlName); + if (!controlBundle) { + return { + promoted: false, + mode, + promotionDetail: `Could not find config bundle "${controlName}" in agentcore.json.`, + }; + } + + const bundleId = bundleIdFromArn(treatment.bundleArn); + if (!bundleId) { + return { promoted: false, mode, promotionDetail: `Could not parse bundle id from ARN "${treatment.bundleArn}".` }; + } + + // Fetch the winning (treatment) version's components from the service and adopt them locally. + // A subsequent `agentcore deploy` version-bumps the bundle (lineage handled server-side). + if (!dryRun) { + const region = regionFromArn(treatment.bundleArn) ?? regionFromArn(record.arn); + if (!region) { + return { promoted: false, mode, promotionDetail: 'Could not determine region for the config bundle.' }; + } + const winning = await getConfigurationBundleVersion({ + region, + bundleId, + versionId: treatment.bundleVersion, + }); + controlBundle.components = winning.components as typeof controlBundle.components; + await configIO.writeProjectSpec(project); + } + return { + promoted: true, + mode, + promotionDetail: `Config bundle "${controlName}" updated to the winning version ${treatment.bundleVersion}.`, + }; +} diff --git a/src/cli/operations/jobs/ab-test/resolve.ts b/src/cli/operations/jobs/ab-test/resolve.ts new file mode 100644 index 000000000..2aafdc6b0 --- /dev/null +++ b/src/cli/operations/jobs/ab-test/resolve.ts @@ -0,0 +1,246 @@ +/** + * Shared AB-test resolution helpers: IAM role create/reuse/delete, and ARN resolution for + * gateway / config-bundle / online-eval references against deployed state. + * + * Extracted from the legacy post-deploy-ab-tests.ts so the AB-test job handler's create() + * can own role + ARN resolution at start time (the config-as-code deploy path is removed). + */ +import type { DeployedResourceState } from '../../../../schema'; +import { getCredentialProvider } from '../../../aws/account'; +import type { ABTestEvaluationConfig, ABTestVariant } from '../../../aws/agentcore-ab-tests'; +import { arnPrefix } from '../../../aws/partition'; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + GetRoleCommand, + IAMClient, + PutRolePolicyCommand, +} from '@aws-sdk/client-iam'; +import { createHash } from 'node:crypto'; + +const AB_TEST_ROLE_POLICY_NAME = 'ABTestExecutionPolicy'; + +/** IAM policy propagation wait after creating/updating the role (ms). */ +export const IAM_PROPAGATION_DELAY_MS = 15_000; + +// ============================================================================ +// IAM role management +// ============================================================================ + +/** Generate a project-scoped role name: AgentCore-{ProjectName}-ABTest{TestName}-{Hash} (max 64 chars). */ +export function generateRoleName(projectName: string, testName: string): string { + // Deterministic hash so retries produce the same role name (avoids orphaned roles). + const hash = createHash('sha256').update(`${projectName}:${testName}`).digest('hex').slice(0, 8); + const base = `AgentCore-${projectName}-ABTest${testName}`; + return `${base.slice(0, 55)}-${hash}`; +} + +/** Extract role name from ARN: arn:aws:iam::123456789012:role/RoleName → RoleName. */ +export function roleNameFromArn(roleArn: string): string { + const parts = roleArn.split('/'); + return parts[parts.length - 1] ?? roleArn; +} + +export interface CreateABTestRoleOptions { + region: string; + projectName: string; + testName: string; + gatewayArn: string; + /** Injectable propagation delay (tests). */ + propagationDelayMs?: number; +} + +/** Create (or reuse) the AB-test execution role + inline policy, then wait for IAM propagation. */ +export async function getOrCreateABTestRole(options: CreateABTestRoleOptions): Promise { + const { region, projectName, testName, gatewayArn } = options; + const credentials = getCredentialProvider(); + const iamClient = new IAMClient({ region, credentials }); + + // Account id from gateway ARN: arn:aws:bedrock-agentcore:REGION:ACCOUNT:gateway/ID + const accountId = gatewayArn.split(':')[4] ?? '*'; + const roleName = generateRoleName(projectName, testName); + + const trustPolicy = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { Service: 'bedrock-agentcore.amazonaws.com' }, + Action: 'sts:AssumeRole', + Condition: { + StringEquals: { 'aws:SourceAccount': accountId }, + ArnLike: { 'aws:SourceArn': `${arnPrefix(region)}:bedrock-agentcore:*:${accountId}:ab-test/*` }, + }, + }, + ], + }); + + let roleArn: string; + try { + const createResult = await iamClient.send( + new CreateRoleCommand({ + RoleName: roleName, + AssumeRolePolicyDocument: trustPolicy, + Description: `Auto-created execution role for AgentCore AB test: ${testName}`, + Tags: [ + { Key: 'agentcore:created-by', Value: 'agentcore-cli' }, + { Key: 'agentcore:project-name', Value: projectName }, + { Key: 'agentcore:ab-test-name', Value: testName }, + ], + }) + ); + roleArn = createResult.Role?.Arn ?? ''; + if (!roleArn) { + throw new Error(`IAM CreateRole succeeded but returned no role ARN for "${roleName}"`); + } + } catch (err: unknown) { + // Retry after a previous failed run left the role behind — reuse it. + if ((err as { name?: string }).name === 'EntityAlreadyExistsException') { + const existing = await iamClient.send(new GetRoleCommand({ RoleName: roleName })); + roleArn = existing.Role?.Arn ?? ''; + if (!roleArn) { + throw new Error(`Role "${roleName}" already exists but ARN could not be retrieved`); + } + } else { + throw err; + } + } + + const policy = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Sid: 'AgentCoreResources', + Effect: 'Allow', + Action: [ + 'bedrock-agentcore:GetGateway', + 'bedrock-agentcore:GetGatewayTarget', + 'bedrock-agentcore:ListGatewayTargets', + 'bedrock-agentcore:CreateGatewayRule', + 'bedrock-agentcore:UpdateGatewayRule', + 'bedrock-agentcore:GetGatewayRule', + 'bedrock-agentcore:DeleteGatewayRule', + 'bedrock-agentcore:ListGatewayRules', + 'bedrock-agentcore:GetOnlineEvaluationConfig', + 'bedrock-agentcore:GetEvaluator', + 'bedrock-agentcore:GetConfigurationBundle', + 'bedrock-agentcore:GetConfigurationBundleVersion', + 'bedrock-agentcore:ListConfigurationBundleVersions', + ], + Resource: `${arnPrefix(region)}:bedrock-agentcore:*:${accountId}:*`, + Condition: { StringEquals: { 'aws:ResourceAccount': accountId } }, + }, + { + Sid: 'CloudWatchLogsDescribe', + Effect: 'Allow', + Action: ['logs:DescribeLogGroups'], + Resource: '*', + }, + { + Sid: 'CloudWatchLogs', + Effect: 'Allow', + Action: [ + 'logs:DescribeIndexPolicies', + 'logs:PutIndexPolicy', + 'logs:StartQuery', + 'logs:GetQueryResults', + 'logs:StopQuery', + 'logs:FilterLogEvents', + 'logs:GetLogEvents', + ], + Resource: [ + `${arnPrefix(region)}:logs:*:${accountId}:log-group:/aws/bedrock-agentcore/evaluations/*`, + `${arnPrefix(region)}:logs:*:${accountId}:log-group:/aws/bedrock-agentcore/runtimes/*`, + `${arnPrefix(region)}:logs:*:${accountId}:log-group:aws/spans`, + `${arnPrefix(region)}:logs:*:${accountId}:log-group:aws/spans:*`, + ], + }, + ], + }); + + // Re-apply the inline policy (idempotent — covers both new and recovered roles). + await iamClient.send( + new PutRolePolicyCommand({ RoleName: roleName, PolicyName: AB_TEST_ROLE_POLICY_NAME, PolicyDocument: policy }) + ); + + // Wait for IAM propagation — both new roles and policy updates on existing roles. + await new Promise(resolve => setTimeout(resolve, options.propagationDelayMs ?? IAM_PROPAGATION_DELAY_MS)); + + return roleArn; +} + +/** Best-effort role cleanup: delete the inline policy then the role. */ +export async function deleteABTestRole(region: string, roleArn: string): Promise { + const credentials = getCredentialProvider(); + const iamClient = new IAMClient({ region, credentials }); + const roleName = roleNameFromArn(roleArn); + + try { + await iamClient.send(new DeleteRolePolicyCommand({ RoleName: roleName, PolicyName: AB_TEST_ROLE_POLICY_NAME })); + } catch { + // policy may not exist + } + try { + await iamClient.send(new DeleteRoleCommand({ RoleName: roleName })); + } catch { + // role may already be deleted or in use — best effort + } +} + +// ============================================================================ +// ARN resolution against deployed state +// ============================================================================ + +/** Resolve a gateway NAME (or {{gateway:name}} placeholder, or ARN) to a gateway ARN. Returns undefined if not deployed. */ +export function resolveGatewayArn(ref: string, deployedResources?: DeployedResourceState): string | undefined { + if (ref.startsWith('arn:')) return ref; + const placeholderMatch = /^\{\{gateway:(.+)\}\}$/.exec(ref); + const gwName = placeholderMatch ? placeholderMatch[1] : ref; + + const mcpGw = gwName ? deployedResources?.mcp?.gateways?.[gwName] : undefined; + if (mcpGw) return mcpGw.gatewayArn; + const httpGw = gwName ? deployedResources?.gateways?.[gwName] : undefined; + if (httpGw) return httpGw.gatewayArn; + + return undefined; +} + +/** + * Resolve a config-bundle name (or ARN) to a bundle ARN. + * Returns undefined when a NAME is given but not found in deployed state (i.e. not deployed), + * so callers can surface a friendly "not deployed" error instead of sending a raw name to the API. + */ +export function resolveConfigBundleArn(ref: string, deployedResources?: DeployedResourceState): string | undefined { + if (ref.startsWith('arn:')) return ref; + const bundle = deployedResources?.configBundles?.[ref]; + return bundle ? bundle.bundleArn : undefined; +} + +/** + * Resolve a config-bundle version, expanding 'LATEST' to the deployed versionId. + * Returns the explicit version verbatim; returns undefined when 'LATEST' cannot be resolved + * (bundle not deployed) so the caller can error rather than send 'LATEST' to the API. + */ +export function resolveConfigBundleVersion( + bundleRef: string, + versionRef: string, + deployedResources?: DeployedResourceState +): string | undefined { + if (versionRef !== 'LATEST') return versionRef; + const name = bundleRef.startsWith('arn:') ? undefined : bundleRef; + const bundle = name ? deployedResources?.configBundles?.[name] : undefined; + return bundle ? bundle.versionId : undefined; +} + +/** + * Resolve an online-eval config name (or ARN) to its ARN. + * Returns undefined when a NAME is given but not found in deployed state (i.e. not deployed). + */ +export function resolveOnlineEvalArn(ref: string, deployedResources?: DeployedResourceState): string | undefined { + if (ref.startsWith('arn:')) return ref; + const config = deployedResources?.onlineEvalConfigs?.[ref]; + return config ? config.onlineEvaluationConfigArn : undefined; +} + +export type { ABTestEvaluationConfig, ABTestVariant }; diff --git a/src/cli/operations/jobs/batch-evaluation/build-source.ts b/src/cli/operations/jobs/batch-evaluation/build-source.ts new file mode 100644 index 000000000..3fb844977 --- /dev/null +++ b/src/cli/operations/jobs/batch-evaluation/build-source.ts @@ -0,0 +1,73 @@ +/** + * Batch-evaluation start-time helpers, extracted from the legacy run-batch-evaluation.ts: + * serviceName / logGroupName construction, evaluator name→short-id resolution (distinct from the + * recommendation ARN resolver), name validation/auto-generation, and the CloudWatch filter builder. + */ +import { ValidationError } from '../../../../lib'; +import type { DeployedState } from '../../../../schema'; +import type { CloudWatchFilterConfig } from '../../../aws/agentcore-batch-evaluation'; +import { resolveEndpointName, runtimeLogGroup } from '../../../aws/cloudwatch'; +import { BATCH_EVAL_NAME_REGEX } from '../shared/constants'; + +/** + * Resolve evaluator references to the SHORT ids the batch API expects. + * Handles "Builtin.Correctness", "arn:...:evaluator/Builtin.Correctness", or custom names + * looked up in deployed state. (Opposite of the recommendation path, which resolves to full ARNs.) + */ +export function resolveBatchEvaluatorIds(deployedState: DeployedState, agent: string, evaluators: string[]): string[] { + const targetResources = Object.values(deployedState.targets).find(t => t.resources?.runtimes?.[agent])?.resources; + return evaluators.map(name => { + const shortName = name.includes('evaluator/') ? name.split('evaluator/').pop()! : name; + if (shortName.startsWith('Builtin.')) return shortName; + const deployed = targetResources?.evaluators?.[shortName]; + if (deployed?.evaluatorId) return deployed.evaluatorId; + return shortName; // pass-through; the service will reject an unknown id + }); +} + +/** CloudWatch service name + log group for the agent's runtime traces. */ +export function buildCloudWatchSource( + projectName: string, + agent: string, + runtimeId: string, + endpoint: string | undefined +): { serviceName: string; logGroupName: string } { + const endpointName = resolveEndpointName(endpoint); + // Service name in CW logs uses project_agent format without the CDK hash suffix. + const serviceName = `${projectName}_${agent}.${endpointName}`; + const logGroupName = runtimeLogGroup(runtimeId, endpoint); + return { serviceName, logGroupName }; +} + +/** Validate an explicit name or auto-generate one. Throws ValidationError on a bad explicit name. */ +export function resolveBatchEvalName(name: string | undefined, projectName: string, agent: string): string { + if (name) { + if (!BATCH_EVAL_NAME_REGEX.test(name)) { + throw new ValidationError( + `Batch evaluation name must start with a letter and contain only letters, digits, and underscores (max 48 chars). Got: "${name}"` + ); + } + return name; + } + return `${projectName}_${agent}_${Date.now()}`.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 48); +} + +/** + * Build the optional CloudWatch filter. The API takes EITHER sessionIds OR timeRange (never both); + * sessionIds take precedence. Returns undefined when neither is provided (evaluate all in the log group). + */ +export function buildCloudWatchFilterConfig( + sessionIds: string[] | undefined, + lookbackDays: number | undefined +): CloudWatchFilterConfig | undefined { + const effective = [...new Set(sessionIds ?? [])]; + if (effective.length > 0) { + return { sessionIds: effective }; + } + if (lookbackDays) { + const endTime = new Date().toISOString(); + const startTime = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString(); + return { timeRange: { startTime, endTime } }; + } + return undefined; +} diff --git a/src/cli/operations/jobs/batch-evaluation/dataset-phase1.ts b/src/cli/operations/jobs/batch-evaluation/dataset-phase1.ts new file mode 100644 index 000000000..9f8f27a27 --- /dev/null +++ b/src/cli/operations/jobs/batch-evaluation/dataset-phase1.ts @@ -0,0 +1,109 @@ +/** + * Dataset Phase-1 for batch evaluation (caller-side, blocking). + * + * The engine's create() is a single API call, but dataset-mode batch evaluation first needs to + * invoke the agent against every dataset scenario and wait for CloudWatch ingestion. That work is + * the CALLER's responsibility (CLI/TUI) — this helper performs it and returns the sessionIds + + * ground-truth sessionMetadata to hand to engine.start('batch-evaluation', ...). + */ +import { ConfigIO } from '../../../../lib'; +import type { Result } from '../../../../lib/result'; +import type { SessionMetadataEntry } from '../../../aws/agentcore-batch-evaluation'; +import { runDatasetScenarios } from '../../eval/shared/dataset-session-provider'; +import { resolveAgentContext } from '../../invoke/resolve-agent-context'; + +/** Delay before submitting batch eval to allow CloudWatch span ingestion. Matches the SDK default. */ +export const BATCH_INGESTION_DELAY_MS = 180_000; + +export interface DatasetPhase1Options { + agent: string; + datasetName: string; + datasetVersion?: string; + endpoint?: string; + configIO?: ConfigIO; + onProgress?: (phase: string, message: string) => void; + /** Override the ingestion wait (tests). */ + ingestionDelayMs?: number; + /** Injectable sleep (tests). */ + sleep?: (ms: number) => Promise; +} + +export type DatasetPhase1Result = Result<{ + sessionIds: string[]; + sessionMetadata: SessionMetadataEntry[]; +}>; + +function defaultSleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** Run dataset scenarios, wait for ingestion, and build sessionIds + ground-truth metadata. */ +export async function runDatasetPhase1(options: DatasetPhase1Options): Promise { + const configIO = options.configIO ?? new ConfigIO(); + const sleep = options.sleep ?? defaultSleep; + + try { + const [projectSpec, deployedState, awsTargets] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readDeployedState(), + configIO.resolveAWSDeploymentTargets(), + ]); + + const agentContext = await resolveAgentContext({ + project: projectSpec, + deployedState, + awsTargets, + agentName: options.agent, + endpoint: options.endpoint, + }); + + options.onProgress?.('invoking', `Invoking agent with dataset "${options.datasetName}"...`); + const datasetResult = await runDatasetScenarios({ + agentContext, + datasetName: options.datasetName, + version: options.datasetVersion, + configBaseDir: configIO.getConfigRoot(), + onProgress: (phase, msg) => options.onProgress?.(phase, msg), + }); + + const successful = datasetResult.scenarioResults.filter(r => r.status === 'success'); + if (successful.length === 0) { + return { success: false, error: new Error('All scenarios failed during invocation. No sessions to evaluate.') }; + } + + const sessionIds = successful.map(r => r.sessionId); + const sessionMetadata = successful.map(r => { + const scenario = datasetResult.scenarios.find(s => s.scenario_id === r.scenarioId); + return { + sessionId: r.sessionId, + testScenarioId: r.scenarioId, + groundTruth: scenario + ? { + inline: { + ...(scenario.assertions ? { assertions: scenario.assertions.map(a => ({ text: a })) } : {}), + ...(scenario.expected_trajectory + ? { expectedTrajectory: { toolNames: scenario.expected_trajectory } } + : {}), + ...(scenario.turns.some(t => t.expectedResponse) + ? { + turns: scenario.turns.map(t => ({ + input: { prompt: t.input }, + ...(t.expectedResponse ? { expectedResponse: { text: t.expectedResponse } } : {}), + })), + } + : {}), + }, + } + : undefined, + }; + }) as SessionMetadataEntry[]; + + options.onProgress?.('invoking', `✓ ${successful.length} sessions ready for batch evaluation`); + options.onProgress?.('ingesting', 'Waiting 180s for CloudWatch span ingestion...'); + await sleep(options.ingestionDelayMs ?? BATCH_INGESTION_DELAY_MS); + + return { success: true, sessionIds, sessionMetadata }; + } catch (err) { + return { success: false, error: err instanceof Error ? err : new Error(String(err)) }; + } +} diff --git a/src/cli/operations/jobs/batch-evaluation/format.ts b/src/cli/operations/jobs/batch-evaluation/format.ts new file mode 100644 index 000000000..b3e8bdee3 --- /dev/null +++ b/src/cli/operations/jobs/batch-evaluation/format.ts @@ -0,0 +1,49 @@ +/** Presentation helpers for batch-evaluation job CLI output (history table + detail view). */ +import { formatJobDate } from '../shared/format'; +import type { BatchEvaluationJobRecord } from '../shared/types'; + +export function printBatchEvaluationHistory(records: BatchEvaluationJobRecord[]): void { + if (records.length === 0) { + console.log('No batch evaluation jobs found. Run `agentcore run batch-evaluation` to create one.'); + return; + } + console.log(`\n${'Date'.padEnd(22)} ${'Status'.padEnd(22)} ${'Evaluators'.padEnd(28)} ${'ID'}`); + console.log('─'.repeat(100)); + for (const r of records) { + console.log( + `${formatJobDate(r.createdAt).padEnd(22)} ${r.status.padEnd(22)} ${r.evaluators.join(', ').padEnd(28)} ${r.id}` + ); + } + console.log(''); +} + +export function printBatchEvaluationDetail(record: BatchEvaluationJobRecord): void { + console.log(`\nBatch evaluation: ${record.id}`); + console.log(`Name: ${record.name}`); + console.log(`Status: ${record.status}`); + console.log(`Agent: ${record.agent}`); + console.log(`Evaluators: ${record.evaluators.join(', ')}`); + console.log(`Started: ${formatJobDate(record.createdAt)}`); + if (record.completedAt) console.log(`Completed: ${formatJobDate(record.completedAt)}`); + if (record.source) console.log(`Source: ${record.source}`); + + const summaries = record.evaluationResults?.evaluatorSummaries; + if (summaries?.length) { + console.log('\nResults:'); + for (const s of summaries) { + const avg = s.statistics?.averageScore; + console.log( + ` ${s.evaluatorId}: ${avg != null ? avg.toFixed(2) : 'N/A'}${s.totalFailed ? ` (${s.totalFailed} failed)` : ''}` + ); + } + } else if (record.results?.length) { + console.log('\nResults:'); + for (const r of record.results) { + console.log(` ${r.evaluatorId}: ${r.score != null ? r.score.toFixed(2) : (r.label ?? 'N/A')}`); + } + } else { + console.log('\nResults not yet available.'); + } + if (record.logFilePath) console.log(`\nLog: ${record.logFilePath}`); + console.log(''); +} diff --git a/src/cli/operations/jobs/batch-evaluation/handler.ts b/src/cli/operations/jobs/batch-evaluation/handler.ts new file mode 100644 index 000000000..754ef2f80 --- /dev/null +++ b/src/cli/operations/jobs/batch-evaluation/handler.ts @@ -0,0 +1,229 @@ +/** + * Batch-evaluation job handler — composes Startable, Refreshable, Stoppable, Archivable. + * + * - create(): resolve agent + evaluators (short ids), build dataSourceConfig (serviceName/logGroup + + * sessionIds|timeRange filter), attach ground-truth metadata, make ONE StartBatchEvaluation + * call, persist the record. Dataset Phase-1 (invoke scenarios + ingestion wait) is the + * caller's responsibility — it supplies sessionIds/sessionMetadata. + * - refresh(): GET latest status; map 404 → NOT_FOUND. On terminal status, fetch per-session scores + * from the CloudWatch output log once (resultsFetched guards + enables retry). + * - stop(): StopBatchEvaluation. + * - archive(): DeleteBatchEvaluation. + */ +import { ConfigIO, JobNotFoundError, ResourceNotFoundError, toError } from '../../../../lib'; +import type { Result } from '../../../../lib/result'; +import { + deleteBatchEvaluation, + generateClientToken, + getBatchEvaluation, + startBatchEvaluation, + stopBatchEvaluation, +} from '../../../aws/agentcore-batch-evaluation'; +import type { BatchEvaluationResultEntry } from '../../../aws/agentcore-batch-evaluation'; +import { detectRegion } from '../../../aws/region'; +import { ExecLogger } from '../../../logging/exec-logger'; +import { NOT_FOUND_STATUS } from '../shared/constants'; +import { regionFromArn, resolveJobRegion } from '../shared/region'; +import { resolveAgentState } from '../shared/resolve-agent-state'; +import type { BatchEvaluationHandler, BatchEvaluationJobRecord, StartBatchEvaluationJobOptions } from '../shared/types'; +import { + buildCloudWatchFilterConfig, + buildCloudWatchSource, + resolveBatchEvalName, + resolveBatchEvaluatorIds, +} from './build-source'; +import { CloudWatchLogsClient, GetLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs'; + +/** Read per-session evaluation scores from the batch's CloudWatch output log stream. */ +async function fetchResultsFromCloudWatch( + region: string, + logGroupName: string, + logStreamName: string +): Promise { + const client = new CloudWatchLogsClient({ region }); + const response = await client.send(new GetLogEventsCommand({ logGroupName, logStreamName, startFromHead: true })); + + const results: BatchEvaluationResultEntry[] = []; + for (const event of response.events ?? []) { + if (!event.message) continue; + try { + const parsed = JSON.parse(event.message) as Record; + const attrs = (parsed.attributes ?? {}) as Record; + const evaluatorId = attrs['gen_ai.evaluation.name'] as string | undefined; + if (!evaluatorId) continue; + results.push({ + evaluatorId, + score: attrs['gen_ai.evaluation.score.value'] as number | undefined, + label: attrs['gen_ai.evaluation.score.label'] as string | undefined, + explanation: attrs['gen_ai.evaluation.explanation'] as string | undefined, + }); + } catch { + // skip non-JSON / malformed entries + } + } + return results; +} + +export const batchEvaluationHandler: BatchEvaluationHandler = { + async create( + opts: StartBatchEvaluationJobOptions, + configIO: ConfigIO + ): Promise> { + let logger: ExecLogger | undefined; + try { + logger = new ExecLogger({ command: 'batch-evaluate' }); + } catch { + // non-fatal + } + + try { + logger?.startStep('Load project config'); + const [projectSpec, deployedState, awsTargets] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readDeployedState(), + configIO.resolveAWSDeploymentTargets(), + ]); + const region = await resolveJobRegion(opts.region, awsTargets); + logger?.endStep('success'); + + logger?.startStep('Resolve agent'); + const agentState = resolveAgentState(deployedState, opts.agent); + if (!agentState) { + const err = new ResourceNotFoundError(`Agent "${opts.agent}" not deployed. Run \`agentcore deploy\` first.`); + logger?.endStep('error', err.message); + logger?.finalize(false); + return { success: false, error: err }; + } + const { serviceName, logGroupName } = buildCloudWatchSource( + projectSpec.name, + opts.agent, + agentState.runtimeId, + opts.endpoint + ); + logger?.log(`Service name: ${serviceName}`); + logger?.log(`Log group: ${logGroupName}`); + logger?.endStep('success'); + + // Resolve name + evaluators (ValidationError on a bad explicit name) + const evalName = resolveBatchEvalName(opts.name, projectSpec.name, opts.agent); + const resolvedEvaluators = resolveBatchEvaluatorIds(deployedState, opts.agent, opts.evaluators); + + // CloudWatch filter — merge explicit sessionIds with any from sessionMetadata, dedup + const metadataSessionIds = opts.sessionMetadata?.map(m => m.sessionId).filter(Boolean) ?? []; + const effectiveSessionIds = [...new Set([...(opts.sessionIds ?? []), ...metadataSessionIds])]; + const filterConfig = buildCloudWatchFilterConfig(effectiveSessionIds, opts.lookbackDays); + + logger?.startStep('Start batch evaluation'); + opts.onProgress?.('starting', `Starting batch evaluation "${evalName}"...`); + const startResult = await startBatchEvaluation({ + region, + name: evalName, + evaluators: resolvedEvaluators.map(id => ({ evaluatorId: id })), + dataSourceConfig: { + cloudWatchLogs: { + serviceNames: [serviceName], + logGroupNames: [logGroupName], + ...(filterConfig ? { filterConfig } : {}), + }, + }, + ...(opts.sessionMetadata && opts.sessionMetadata.length > 0 + ? { evaluationMetadata: { sessionMetadata: opts.sessionMetadata } } + : {}), + ...(opts.kmsKeyArn ? { kmsKeyArn: opts.kmsKeyArn } : {}), + clientToken: generateClientToken(), + }); + logger?.log(`Response: ${JSON.stringify(startResult, null, 2)}`); + logger?.endStep('success'); + opts.onProgress?.( + 'started', + `Batch evaluation created: ${startResult.batchEvaluationId} (${startResult.status})` + ); + logger?.finalize(true); + + const record: BatchEvaluationJobRecord = { + type: 'batch-evaluation', + id: startResult.batchEvaluationId, + arn: startResult.batchEvaluationArn, + status: startResult.status, + createdAt: startResult.createdAt ?? new Date().toISOString(), + agent: opts.agent, + logFilePath: logger?.logFilePath, + name: evalName, + evaluators: resolvedEvaluators, + source: opts.source, + dataset: opts.dataset, + ...(opts.kmsKeyArn ? { kmsKeyArn: opts.kmsKeyArn } : {}), + }; + return { success: true, record }; + } catch (err) { + logger?.finalize(false); + return { success: false, error: toError(err) }; + } + }, + + async refresh(record: BatchEvaluationJobRecord): Promise> { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + let response; + try { + response = await getBatchEvaluation({ region, batchEvaluationId: record.id }); + } catch (err) { + if (err instanceof JobNotFoundError) { + return { success: true, record: { ...record, status: NOT_FOUND_STATUS, resultsFetched: true } }; + } + return { success: false, error: toError(err) }; + } + + const updated: BatchEvaluationJobRecord = { + ...record, + status: response.status, + completedAt: response.updatedAt ?? record.completedAt, + evaluationResults: response.evaluationResults ?? record.evaluationResults, + kmsKeyArn: response.kmsKeyArn ?? record.kmsKeyArn, + }; + + // Fetch per-session scores from the CloudWatch output log once the job is terminal. + const isTerminalStatus = ['COMPLETED', 'COMPLETED_WITH_ERRORS', 'FAILED', 'STOPPED', 'CANCELLED'].includes( + response.status + ); + const cw = response.outputConfig?.cloudWatchConfig; + if (isTerminalStatus && !record.resultsFetched && cw) { + try { + const results = await fetchResultsFromCloudWatch(region, cw.logGroupName, cw.logStreamName); + // Never clobber populated results with an empty re-read. + if (results.length > 0 || !record.results?.length) { + updated.results = results; + } + updated.resultsFetched = true; + } catch { + // leave resultsFetched false so the next get()/list() retries + } + } else if (isTerminalStatus && !cw) { + // Terminal with no output log destination — nothing to fetch; mark settled. + updated.resultsFetched = true; + } + return { success: true, record: updated }; + }, + + async stop(record: BatchEvaluationJobRecord): Promise { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + try { + await stopBatchEvaluation({ region, batchEvaluationId: record.id }); + return { success: true }; + } catch (err) { + return { success: false, error: toError(err) }; + } + }, + + async archive(record: BatchEvaluationJobRecord): Promise { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + try { + await deleteBatchEvaluation({ region, batchEvaluationId: record.id }); + return { success: true }; + } catch (err) { + if (err instanceof JobNotFoundError) { + return { success: true }; + } + return { success: false, error: toError(err) }; + } + }, +}; diff --git a/src/cli/operations/jobs/index.ts b/src/cli/operations/jobs/index.ts new file mode 100644 index 000000000..74b9ce021 --- /dev/null +++ b/src/cli/operations/jobs/index.ts @@ -0,0 +1,46 @@ +/** + * Job Engine public API. + * + * Usage: + * const engine = createJobEngine(new ConfigIO()); + * const r = await engine.start('recommendation', opts); + * const jobs = await engine.list({ type: 'recommendation' }); + */ +export { createJobEngine } from './shared/engine'; +export { isTerminal, JOB_CAPABILITIES, STORAGE_DIRS, TERMINAL_STATUSES, NOT_FOUND_STATUS } from './shared/constants'; +export { regionFromArn } from './shared/region'; +export { waitForTerminal } from './shared/wait'; +export type { WaitForTerminalOptions } from './shared/wait'; +export { runDatasetPhase1, BATCH_INGESTION_DELAY_MS } from './batch-evaluation/dataset-phase1'; +export type { DatasetPhase1Result } from './batch-evaluation/dataset-phase1'; + +export type { + JobEngine, + JobType, + JobRecord, + JobRecordBase, + RecommendationJobRecord, + BatchEvaluationJobRecord, + ABTestJobRecord, + ABTestVariantSummary, + ABTestMode, + InsightsJobRecord, + JobCapabilities, + ListOptions, + StartRecommendationJobOptions, + StartBatchEvaluationJobOptions, + StartABTestJobOptions, + StartInsightsJobOptions, + RecommendationInputSource, + RecommendationTraceSource, + BatchEvaluationSource, + ToolDescJsonPath, + RecommendationType, + PausableJobType, + PromotableJobType, + StoppableJobType, + DebuggableJobType, + DebugCheckResult, + FailureAnalysisResult, + InsightFailureCategory, +} from './shared/types'; diff --git a/src/cli/operations/jobs/insights/__tests__/handler.test.ts b/src/cli/operations/jobs/insights/__tests__/handler.test.ts new file mode 100644 index 000000000..e69a543af --- /dev/null +++ b/src/cli/operations/jobs/insights/__tests__/handler.test.ts @@ -0,0 +1,24 @@ +import { validateLookbackDays } from '../handler.js'; +import { describe, expect, it } from 'vitest'; + +describe('validateLookbackDays', () => { + it('accepts positive integers', () => { + expect(() => validateLookbackDays(1)).not.toThrow(); + expect(() => validateLookbackDays(7)).not.toThrow(); + expect(() => validateLookbackDays(30)).not.toThrow(); + }); + + it('rejects negative values', () => { + expect(() => validateLookbackDays(-5)).toThrow('positive integer'); + expect(() => validateLookbackDays(-1)).toThrow('positive integer'); + }); + + it('rejects zero', () => { + expect(() => validateLookbackDays(0)).toThrow('positive integer'); + }); + + it('rejects non-integer values', () => { + expect(() => validateLookbackDays(2.5)).toThrow('positive integer'); + expect(() => validateLookbackDays(0.5)).toThrow('positive integer'); + }); +}); diff --git a/src/cli/operations/jobs/insights/format.ts b/src/cli/operations/jobs/insights/format.ts new file mode 100644 index 000000000..e509b2174 --- /dev/null +++ b/src/cli/operations/jobs/insights/format.ts @@ -0,0 +1,75 @@ +/** Presentation helpers for insights job CLI output (history table + detail view). */ +import { formatJobDate } from '../shared/format'; +import type { InsightsJobRecord } from '../shared/types'; + +export function printInsightsHistory(records: InsightsJobRecord[]): void { + if (records.length === 0) { + console.log('No insights jobs found. Run `agentcore run insights` to create one.'); + return; + } + console.log(`\n${'Date'.padEnd(22)} ${'Status'.padEnd(22)} ${'Insights'.padEnd(28)} ${'ID'}`); + console.log('─'.repeat(100)); + for (const r of records) { + console.log( + `${formatJobDate(r.createdAt).padEnd(22)} ${r.status.padEnd(22)} ${r.insights.join(', ').padEnd(28)} ${r.id}` + ); + } + console.log(''); +} + +export function printInsightsDetail(record: InsightsJobRecord): void { + console.log(`\nInsights job: ${record.id}`); + console.log(`Name: ${record.name}`); + console.log(`Status: ${record.status}`); + console.log(`Agent: ${record.agent}`); + console.log(`Insights: ${record.insights.join(', ')}`); + if (record.evaluators?.length) { + console.log(`Evaluators: ${record.evaluators.join(', ')}`); + } + console.log(`Started: ${formatJobDate(record.createdAt)}`); + if (record.completedAt) console.log(`Completed: ${formatJobDate(record.completedAt)}`); + + const fa = record.failureAnalysisResult; + if (fa?.failureCategories?.length) { + console.log('\nFailure Analysis:'); + for (const cat of fa.failureCategories) { + console.log(`\n Category: ${cat.failureCategoryName ?? 'Unknown'}`); + if (cat.failureCategoryDescription) { + console.log(` Description: ${cat.failureCategoryDescription}`); + } + if (cat.categoryGroupName) { + console.log(` Group: ${cat.categoryGroupName}`); + } + if (cat.rootCauses?.length) { + for (const rc of cat.rootCauses) { + console.log(` Root cause: ${rc.rootCauseCategory ?? 'Unknown'}`); + if (rc.rootCauseDescription) { + console.log(` ${rc.rootCauseDescription}`); + } + if (rc.recommendation) { + console.log(` Recommendation: ${rc.recommendation}`); + } + if (rc.relatedSessions?.length) { + console.log(` Sessions: ${rc.relatedSessions.map(s => s.sessionId).join(', ')}`); + } + } + } + } + } else { + const evalResults = record.evaluationResults; + if (evalResults?.evaluatorSummaries?.length) { + console.log('\nEvaluation Results:'); + for (const s of evalResults.evaluatorSummaries) { + const avg = s.statistics?.averageScore; + console.log( + ` ${s.evaluatorId}: ${avg != null ? avg.toFixed(2) : 'N/A'}${s.totalFailed ? ` (${s.totalFailed} failed)` : ''}` + ); + } + } else { + console.log('\nResults not yet available.'); + } + } + + if (record.logFilePath) console.log(`\nLog: ${record.logFilePath}`); + console.log(''); +} diff --git a/src/cli/operations/jobs/insights/handler.ts b/src/cli/operations/jobs/insights/handler.ts new file mode 100644 index 000000000..b822b8a73 --- /dev/null +++ b/src/cli/operations/jobs/insights/handler.ts @@ -0,0 +1,212 @@ +/** + * Insights job handler — composes Startable, Refreshable, Archivable. + * + * - create(): resolve agent, build dataSourceConfig (cloudWatchLogs or onlineEvaluationConfigSource), + * send `insights` field (optionally with evaluators for recommendation chaining), + * call startBatchEvaluation, persist the record. + * - refresh(): GET latest status; map 404 -> NOT_FOUND. Parse failureAnalysisResult from response. + * - archive(): DeleteBatchEvaluation. + * + * Insights jobs are NOT stoppable. + */ +import { ConfigIO, JobNotFoundError, ResourceNotFoundError, ValidationError, toError } from '../../../../lib'; +import type { Result } from '../../../../lib/result'; +import { + deleteBatchEvaluation, + generateClientToken, + getBatchEvaluation, + startBatchEvaluation, +} from '../../../aws/agentcore-batch-evaluation'; +import type { CloudWatchFilterConfig, DataSourceConfig } from '../../../aws/agentcore-batch-evaluation'; +import { resolveEndpointName, runtimeLogGroup } from '../../../aws/cloudwatch'; +import { detectRegion } from '../../../aws/region'; +import { ExecLogger } from '../../../logging/exec-logger'; +import { resolveBatchEvaluatorIds } from '../batch-evaluation/build-source'; +import { NOT_FOUND_STATUS } from '../shared/constants'; +import { regionFromArn, resolveJobRegion } from '../shared/region'; +import { resolveAgentState } from '../shared/resolve-agent-state'; +import type { InsightsHandler, InsightsJobRecord, StartInsightsJobOptions } from '../shared/types'; + +const NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; + +/** Auto-generate a job name from project/agent/timestamp, validating user-provided names. */ +function resolveInsightsName(name: string | undefined, projectName: string, agent: string): string { + if (name) { + if (!NAME_PATTERN.test(name)) { + throw new ValidationError( + `Job name "${name}" is invalid. Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars).` + ); + } + return name; + } + return `${projectName}_${agent}_insights_${Date.now()}`.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 48); +} + +/** Build the CloudWatch filter config for session/time filtering. */ +function buildFilterConfig( + sessionIds: string[] | undefined, + lookbackDays: number | undefined, + startTime: string | undefined, + endTime: string | undefined +): CloudWatchFilterConfig | undefined { + const effective = [...new Set(sessionIds ?? [])]; + if (effective.length > 0) { + return { sessionIds: effective }; + } + if (startTime || endTime) { + return { timeRange: { startTime, endTime } }; + } + if (lookbackDays !== undefined) { + validateLookbackDays(lookbackDays); + const end = new Date().toISOString(); + const start = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString(); + return { timeRange: { startTime: start, endTime: end } }; + } + return undefined; +} + +export const insightsHandler: InsightsHandler = { + async create(opts: StartInsightsJobOptions, configIO: ConfigIO): Promise> { + let logger: ExecLogger | undefined; + try { + logger = new ExecLogger({ command: 'insights' }); + } catch { + // non-fatal + } + + try { + logger?.startStep('Load project config'); + const [projectSpec, deployedState, awsTargets] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readDeployedState(), + configIO.resolveAWSDeploymentTargets(), + ]); + const region = await resolveJobRegion(opts.region, awsTargets); + logger?.endStep('success'); + + // Determine agent name — required unless using onlineEvalConfigArn + const agentName = opts.agent ?? projectSpec.runtimes?.[0]?.name ?? ''; + + let dataSourceConfig: DataSourceConfig; + + if (opts.onlineEvalConfigArn) { + // Use existing online evaluation config as the session source + logger?.startStep('Use online eval config source'); + dataSourceConfig = { + onlineEvaluationConfigSource: { onlineEvaluationConfigArn: opts.onlineEvalConfigArn }, + }; + logger?.endStep('success'); + } else { + // Build CloudWatch logs source + logger?.startStep('Resolve agent'); + const agentState = resolveAgentState(deployedState, agentName); + if (!agentState) { + const err = new ResourceNotFoundError(`Agent "${agentName}" not deployed. Run \`agentcore deploy\` first.`); + logger?.endStep('error', err.message); + logger?.finalize(false); + return { success: false, error: err }; + } + + const endpointName = resolveEndpointName(opts.endpoint); + const serviceName = `${projectSpec.name}_${agentName}.${endpointName}`; + const logGroupName = runtimeLogGroup(agentState.runtimeId, opts.endpoint); + logger?.log(`Service name: ${serviceName}`); + logger?.log(`Log group: ${logGroupName}`); + logger?.endStep('success'); + + const filterConfig = buildFilterConfig(opts.sessionIds, opts.lookbackDays, opts.startTime, opts.endTime); + dataSourceConfig = { + cloudWatchLogs: { + serviceNames: [serviceName], + logGroupNames: [logGroupName], + ...(filterConfig ? { filterConfig } : {}), + }, + }; + } + + const evalName = resolveInsightsName(opts.name, projectSpec.name, agentName); + + // Resolve evaluators if provided (for recommendation chaining) + const resolvedEvaluators = opts.evaluators?.length + ? resolveBatchEvaluatorIds(deployedState, agentName, opts.evaluators) + : undefined; + + logger?.startStep('Start insights job'); + opts.onProgress?.('starting', `Starting insights job "${evalName}"...`); + const startResult = await startBatchEvaluation({ + region, + name: evalName, + ...(!opts.onlineEvalConfigArn && { insights: opts.insights.map(id => ({ insightId: id })) }), + ...(!opts.onlineEvalConfigArn && resolvedEvaluators && resolvedEvaluators.length > 0 + ? { evaluators: resolvedEvaluators.map(id => ({ evaluatorId: id })) } + : {}), + dataSourceConfig, + clientToken: generateClientToken(), + }); + logger?.log(`Response: ${JSON.stringify(startResult, null, 2)}`); + logger?.endStep('success'); + opts.onProgress?.('started', `Insights job created: ${startResult.batchEvaluationId} (${startResult.status})`); + logger?.finalize(true); + + const record: InsightsJobRecord = { + type: 'insights', + id: startResult.batchEvaluationId, + arn: startResult.batchEvaluationArn, + status: startResult.status, + createdAt: startResult.createdAt ?? new Date().toISOString(), + agent: agentName, + logFilePath: logger?.logFilePath, + name: evalName, + insights: opts.insights, + evaluators: resolvedEvaluators, + }; + return { success: true, record }; + } catch (err) { + logger?.finalize(false); + return { success: false, error: toError(err) }; + } + }, + + async refresh(record: InsightsJobRecord): Promise> { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + let response; + try { + response = await getBatchEvaluation({ region, batchEvaluationId: record.id }); + } catch (err) { + if (err instanceof JobNotFoundError) { + return { success: true, record: { ...record, status: NOT_FOUND_STATUS } }; + } + return { success: false, error: toError(err) }; + } + + const updated: InsightsJobRecord = { + ...record, + status: response.status, + completedAt: response.updatedAt ?? record.completedAt, + evaluationResults: response.evaluationResults ?? record.evaluationResults, + failureAnalysisResult: response.failureAnalysisResult ?? record.failureAnalysisResult, + }; + + return { success: true, record: updated }; + }, + + async archive(record: InsightsJobRecord): Promise { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + try { + await deleteBatchEvaluation({ region, batchEvaluationId: record.id }); + return { success: true }; + } catch (err) { + if (err instanceof JobNotFoundError) { + return { success: true }; + } + return { success: false, error: toError(err) }; + } + }, +}; + +/** Validates that lookbackDays is a positive integer. */ +export function validateLookbackDays(lookbackDays: number): void { + if (!Number.isInteger(lookbackDays) || lookbackDays < 1) { + throw new ValidationError('--lookback-days must be a positive integer (at least 1).'); + } +} diff --git a/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts b/src/cli/operations/jobs/recommendation/__tests__/apply-to-bundle.test.ts similarity index 97% rename from src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts rename to src/cli/operations/jobs/recommendation/__tests__/apply-to-bundle.test.ts index 981f00a75..564407293 100644 --- a/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts +++ b/src/cli/operations/jobs/recommendation/__tests__/apply-to-bundle.test.ts @@ -1,5 +1,5 @@ -import type { ConfigIO } from '../../../../lib'; -import type { RecommendationResult } from '../../../aws/agentcore-recommendation'; +import type { ConfigIO } from '../../../../../lib'; +import type { RecommendationResult } from '../../../../aws/agentcore-recommendation'; import { applyRecommendationToBundle } from '../apply-to-bundle'; import assert from 'node:assert'; import { describe, expect, it, vi } from 'vitest'; @@ -10,7 +10,7 @@ const { RUNTIME_ARN, BUNDLE_ARN, NEW_VERSION_ID } = vi.hoisted(() => ({ NEW_VERSION_ID: 'v2-recommendation', })); -vi.mock('../../../aws/agentcore-config-bundles', () => ({ +vi.mock('../../../../aws/agentcore-config-bundles', () => ({ getConfigurationBundleVersion: vi.fn().mockResolvedValue({ bundleArn: BUNDLE_ARN, bundleId: 'MyBundle-xyz789', diff --git a/src/cli/operations/jobs/recommendation/__tests__/auto-name.test.ts b/src/cli/operations/jobs/recommendation/__tests__/auto-name.test.ts new file mode 100644 index 000000000..0773bb998 --- /dev/null +++ b/src/cli/operations/jobs/recommendation/__tests__/auto-name.test.ts @@ -0,0 +1,39 @@ +import { RECOMMENDATION_NAME_REGEX } from '../../shared/constants'; +import { autoRecommendationName } from '../handler'; +import { describe, expect, it } from 'vitest'; + +const FIXED_NOW = 1781546192034; // 13-digit ms timestamp + +describe('autoRecommendationName', () => { + it('caps long project/agent names to the 48-char service limit (regression: AfricanTripPlanner)', () => { + const name = autoRecommendationName('AfricanTripPlanner', 'AfricanTripPlanner', FIXED_NOW); + expect(name.length).toBeLessThanOrEqual(48); + expect(RECOMMENDATION_NAME_REGEX.test(name)).toBe(true); + // The full timestamp is preserved as the suffix for uniqueness. + expect(name.endsWith(`_${FIXED_NOW}`)).toBe(true); + }); + + it('leaves short names intact', () => { + const name = autoRecommendationName('Trip', 'Planner', FIXED_NOW); + expect(name).toBe(`Trip_Planner_${FIXED_NOW}`); + expect(RECOMMENDATION_NAME_REGEX.test(name)).toBe(true); + }); + + it('produces a valid name even when the prefix would start with a non-letter', () => { + const name = autoRecommendationName('123proj', 'agent', FIXED_NOW); + expect(RECOMMENDATION_NAME_REGEX.test(name)).toBe(true); + expect(/^[a-zA-Z]/.test(name)).toBe(true); + }); + + it('sanitizes characters the service regex forbids', () => { + const name = autoRecommendationName('my proj.name', 'my/agent', FIXED_NOW); + expect(RECOMMENDATION_NAME_REGEX.test(name)).toBe(true); + expect(name).not.toMatch(/[ ./]/); + }); + + it('stays within 48 chars for very long inputs', () => { + const name = autoRecommendationName('VeryLongProjectNameThatExceedsLimits', 'AndAVeryLongAgentNameToo', FIXED_NOW); + expect(name.length).toBeLessThanOrEqual(48); + expect(RECOMMENDATION_NAME_REGEX.test(name)).toBe(true); + }); +}); diff --git a/src/cli/operations/recommendation/__tests__/fetch-session-spans.test.ts b/src/cli/operations/jobs/recommendation/__tests__/fetch-session-spans.test.ts similarity index 99% rename from src/cli/operations/recommendation/__tests__/fetch-session-spans.test.ts rename to src/cli/operations/jobs/recommendation/__tests__/fetch-session-spans.test.ts index e95b9b499..f1095719f 100644 --- a/src/cli/operations/recommendation/__tests__/fetch-session-spans.test.ts +++ b/src/cli/operations/jobs/recommendation/__tests__/fetch-session-spans.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockSearchLogs = vi.fn(); -vi.mock('../../../aws/cloudwatch', () => ({ +vi.mock('../../../../aws/cloudwatch', () => ({ searchLogs: (...args: unknown[]) => mockSearchLogs(...args), runtimeLogGroup: (runtimeId: string) => `/aws/bedrock-agentcore/runtimes/${runtimeId}-DEFAULT`, })); diff --git a/src/cli/operations/jobs/recommendation/__tests__/input-validation.test.ts b/src/cli/operations/jobs/recommendation/__tests__/input-validation.test.ts new file mode 100644 index 000000000..dead42af0 --- /dev/null +++ b/src/cli/operations/jobs/recommendation/__tests__/input-validation.test.ts @@ -0,0 +1,155 @@ +import { ValidationError } from '../../../../../lib'; +import { + MAX_INLINE_SPANS, + MAX_TOOL_NAME_LENGTH, + RECOMMENDATION_NAME_REGEX, + TOOL_NAME_REGEX, +} from '../../shared/constants'; +import { buildRecommendationConfig } from '../build-config'; +import { describe, expect, it } from 'vitest'; + +describe('RECOMMENDATION_NAME_REGEX', () => { + it('accepts valid names', () => { + expect(RECOMMENDATION_NAME_REGEX.test('myRec')).toBe(true); + expect(RECOMMENDATION_NAME_REGEX.test('A123_test-run')).toBe(true); + expect(RECOMMENDATION_NAME_REGEX.test('a')).toBe(true); + expect(RECOMMENDATION_NAME_REGEX.test('a'.repeat(48))).toBe(true); + }); + + it('rejects names starting with a number', () => { + expect(RECOMMENDATION_NAME_REGEX.test('1badName')).toBe(false); + }); + + it('rejects names with spaces', () => { + expect(RECOMMENDATION_NAME_REGEX.test('my rec')).toBe(false); + }); + + it('rejects names with special characters', () => { + expect(RECOMMENDATION_NAME_REGEX.test('my.rec')).toBe(false); + expect(RECOMMENDATION_NAME_REGEX.test('rec@name')).toBe(false); + }); + + it('rejects names exceeding 48 characters', () => { + expect(RECOMMENDATION_NAME_REGEX.test('a'.repeat(49))).toBe(false); + }); + + it('rejects empty string', () => { + expect(RECOMMENDATION_NAME_REGEX.test('')).toBe(false); + }); +}); + +describe('TOOL_NAME_REGEX', () => { + it('accepts valid tool names', () => { + expect(TOOL_NAME_REGEX.test('search')).toBe(true); + expect(TOOL_NAME_REGEX.test('my_tool')).toBe(true); + expect(TOOL_NAME_REGEX.test('my-tool')).toBe(true); + expect(TOOL_NAME_REGEX.test('my.tool.v2')).toBe(true); + expect(TOOL_NAME_REGEX.test('Tool123')).toBe(true); + }); + + it('rejects tool names with spaces', () => { + expect(TOOL_NAME_REGEX.test('my tool')).toBe(false); + }); + + it('rejects tool names with special characters', () => { + expect(TOOL_NAME_REGEX.test('tool@name')).toBe(false); + expect(TOOL_NAME_REGEX.test('tool/name')).toBe(false); + expect(TOOL_NAME_REGEX.test('tool:name')).toBe(false); + }); + + it('rejects empty string', () => { + expect(TOOL_NAME_REGEX.test('')).toBe(false); + }); +}); + +describe('buildRecommendationConfig — tool name validation', () => { + const baseOpts = { + type: 'TOOL_DESCRIPTION_RECOMMENDATION' as const, + inputSource: 'inline', + traceSource: 'batch-evaluation', + batchEvaluationArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:batch-evaluation/test-ABCDE12345', + runtimeId: 'proj_agent-abc123', + accountId: '123456789012', + region: 'us-east-1', + evaluatorIds: ['arn:aws:bedrock-agentcore:::evaluator/Builtin.Correctness'], + }; + + it('rejects tool names with spaces', async () => { + await expect(buildRecommendationConfig({ ...baseOpts, tools: ['my tool:does stuff'] })).rejects.toThrow( + ValidationError + ); + }); + + it('rejects tool names exceeding max length', async () => { + const longName = 'a'.repeat(MAX_TOOL_NAME_LENGTH + 1); + await expect(buildRecommendationConfig({ ...baseOpts, tools: [`${longName}:description`] })).rejects.toThrow( + ValidationError + ); + }); + + it('accepts valid tool names', async () => { + const result = await buildRecommendationConfig({ ...baseOpts, tools: ['my_tool-v2.0:Does things'] }); + expect(result.toolDescriptionRecommendationConfig).toBeDefined(); + }); +}); + +describe('buildRecommendationConfig — spans limit validation', () => { + it('rejects spans file exceeding max count', async () => { + const { writeFileSync, mkdtempSync, rmSync } = await import('fs'); + const { join } = await import('path'); + const tmpDir = mkdtempSync('/tmp/spans-test-'); + const spansFile = join(tmpDir, 'spans.json'); + try { + const spans = Array.from({ length: MAX_INLINE_SPANS + 1 }, (_, i) => ({ + traceId: `trace-${i}`, + spanId: `span-${i}`, + })); + writeFileSync(spansFile, JSON.stringify(spans)); + + await expect( + buildRecommendationConfig({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + inlineContent: 'You are helpful', + inputSource: 'inline', + traceSource: 'spans-file', + spansFile, + runtimeId: 'proj_agent-abc123', + accountId: '123456789012', + region: 'us-east-1', + evaluatorIds: ['arn:aws:bedrock-agentcore:::evaluator/Builtin.Correctness'], + }) + ).rejects.toThrow(ValidationError); + } finally { + rmSync(tmpDir, { recursive: true }); + } + }); + + it('accepts spans file within limit', async () => { + const { writeFileSync, mkdtempSync, rmSync } = await import('fs'); + const { join } = await import('path'); + const tmpDir = mkdtempSync('/tmp/spans-test-'); + const spansFile = join(tmpDir, 'spans.json'); + try { + const spans = Array.from({ length: 5 }, (_, i) => ({ + traceId: `trace-${i}`, + spanId: `span-${i}`, + })); + writeFileSync(spansFile, JSON.stringify(spans)); + + const result = await buildRecommendationConfig({ + type: 'SYSTEM_PROMPT_RECOMMENDATION', + inlineContent: 'You are helpful', + inputSource: 'inline', + traceSource: 'spans-file', + spansFile, + runtimeId: 'proj_agent-abc123', + accountId: '123456789012', + region: 'us-east-1', + evaluatorIds: ['arn:aws:bedrock-agentcore:::evaluator/Builtin.Correctness'], + }); + expect(result.systemPromptRecommendationConfig).toBeDefined(); + } finally { + rmSync(tmpDir, { recursive: true }); + } + }); +}); diff --git a/src/cli/operations/jobs/recommendation/__tests__/refresh.test.ts b/src/cli/operations/jobs/recommendation/__tests__/refresh.test.ts new file mode 100644 index 000000000..fa65cfc9f --- /dev/null +++ b/src/cli/operations/jobs/recommendation/__tests__/refresh.test.ts @@ -0,0 +1,102 @@ +import { getRecommendation } from '../../../../aws/agentcore-recommendation'; +import type { GetRecommendationResult } from '../../../../aws/agentcore-recommendation'; +import type { RecommendationJobRecord } from '../../shared/types'; +import { recommendationHandler } from '../handler'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../aws/agentcore-recommendation', () => ({ + getRecommendation: vi.fn(), +})); + +vi.mock('../../../../aws/region', () => ({ + detectRegion: vi.fn().mockResolvedValue({ region: 'us-west-2' }), +})); + +const mockGet = vi.mocked(getRecommendation); + +/** A record carrying a region-bearing ARN so refresh() never needs detectRegion(). */ +function baseRecord(overrides: Partial = {}): RecommendationJobRecord { + return { + type: 'recommendation', + id: 'rec-123', + arn: 'arn:aws:bedrock-agentcore:us-west-2:111122223333:recommendation/rec-123', + status: 'PENDING', + createdAt: '2026-06-16T01:00:00.000Z', + agent: 'MyAgent', + recommendationType: 'SYSTEM_PROMPT_RECOMMENDATION', + evaluators: ['Builtin.Correctness'], + inputSource: 'inline', + ...overrides, + }; +} + +function getResult(overrides: Partial): GetRecommendationResult { + return { + recommendationId: 'rec-123', + recommendationArn: 'arn:aws:bedrock-agentcore:us-west-2:111122223333:recommendation/rec-123', + name: 'rec', + type: 'SYSTEM_PROMPT_RECOMMENDATION', + status: 'PENDING', + ...overrides, + }; +} + +describe('recommendationHandler.refresh — completedAt only on terminal status', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does NOT set completedAt while IN_PROGRESS even though updatedAt advances', async () => { + mockGet.mockResolvedValue(getResult({ status: 'IN_PROGRESS', updatedAt: '2026-06-16T01:00:05.000Z' })); + + const result = await recommendationHandler.refresh(baseRecord({ status: 'PENDING' })); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.record.status).toBe('IN_PROGRESS'); + // Regression: a running job must not look completed. + expect(result.record.completedAt).toBeUndefined(); + }); + + it('clears a stale completedAt left by a prior buggy refresh when still IN_PROGRESS', async () => { + mockGet.mockResolvedValue(getResult({ status: 'IN_PROGRESS', updatedAt: '2026-06-16T01:00:05.000Z' })); + + const result = await recommendationHandler.refresh( + baseRecord({ status: 'IN_PROGRESS', completedAt: '2026-06-16T01:00:01.000Z' }) + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.record.completedAt).toBeUndefined(); + }); + + it('sets completedAt from the service completedAt once COMPLETED', async () => { + mockGet.mockResolvedValue( + getResult({ + status: 'COMPLETED', + completedAt: '2026-06-16T01:05:00.000Z', + updatedAt: '2026-06-16T01:05:00.000Z', + }) + ); + + const result = await recommendationHandler.refresh(baseRecord({ status: 'IN_PROGRESS' })); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.record.status).toBe('COMPLETED'); + expect(result.record.completedAt).toBe('2026-06-16T01:05:00.000Z'); + }); + + it('falls back to updatedAt for a terminal status with no completedAt (e.g. FAILED)', async () => { + mockGet.mockResolvedValue( + getResult({ status: 'FAILED', updatedAt: '2026-06-16T01:02:00.000Z', statusReasons: ['boom'] }) + ); + + const result = await recommendationHandler.refresh(baseRecord({ status: 'IN_PROGRESS' })); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.record.status).toBe('FAILED'); + expect(result.record.completedAt).toBe('2026-06-16T01:02:00.000Z'); + }); +}); diff --git a/src/cli/operations/recommendation/apply-to-bundle.ts b/src/cli/operations/jobs/recommendation/apply-to-bundle.ts similarity index 94% rename from src/cli/operations/recommendation/apply-to-bundle.ts rename to src/cli/operations/jobs/recommendation/apply-to-bundle.ts index 26d0f00c6..f9a5e779b 100644 --- a/src/cli/operations/recommendation/apply-to-bundle.ts +++ b/src/cli/operations/jobs/recommendation/apply-to-bundle.ts @@ -9,10 +9,10 @@ * This module fetches that new version via GetConfigurationBundleVersion and * updates the local agentcore.json components to match the server state. */ -import { ConfigIO, ResourceNotFoundError } from '../../../lib'; -import type { Result } from '../../../lib/result'; -import { getConfigurationBundleVersion } from '../../aws/agentcore-config-bundles'; -import type { RecommendationResult } from '../../aws/agentcore-recommendation'; +import { ConfigIO, ResourceNotFoundError } from '../../../../lib'; +import type { Result } from '../../../../lib/result'; +import { getConfigurationBundleVersion } from '../../../aws/agentcore-config-bundles'; +import type { RecommendationResult } from '../../../aws/agentcore-recommendation'; export interface ApplyRecommendationOptions { /** Config bundle name in agentcore.json (used by CLI) */ diff --git a/src/cli/operations/jobs/recommendation/build-config.ts b/src/cli/operations/jobs/recommendation/build-config.ts new file mode 100644 index 000000000..21dd00e71 --- /dev/null +++ b/src/cli/operations/jobs/recommendation/build-config.ts @@ -0,0 +1,310 @@ +/** + * Recommendation start-time pipeline, extracted from the legacy run-recommendation.ts so the + * job handler's create() can reuse it. Owns: evaluator name→ARN resolution, account-id extraction, + * config-bundle JSONPath component resolution, structured failure extraction, and the + * recommendationConfig builder (which includes the slow sessions/spans-file span fetch). + */ +import { ValidationError } from '../../../../lib'; +import type { DeployedState } from '../../../../schema'; +import type { + RecommendationConfig, + RecommendationEvaluationConfig, + RecommendationResult, + RecommendationType, + SessionSpan, +} from '../../../aws/agentcore-recommendation'; +import { runtimeLogGroup } from '../../../aws/cloudwatch'; +import { arnPrefix } from '../../../aws/partition'; +import type { ExecLogger } from '../../../logging/exec-logger'; +import { MAX_INLINE_SPANS, MAX_TOOL_NAME_LENGTH, TOOL_NAME_REGEX } from '../shared/constants'; +import { fetchSessionSpans } from './fetch-session-spans'; +import { readFileSync } from 'fs'; + +/** Resolve an evaluator reference to a full ARN (ARN passthrough, Builtin.* expansion, or deployed lookup). */ +export function resolveEvaluatorId( + deployedState: DeployedState, + evaluator: string, + region: string +): string | undefined { + // Already a full ARN — use as-is + if (evaluator.startsWith('arn:')) { + return evaluator; + } + // Builtin shorthand → expand to full ARN + if (evaluator.startsWith('Builtin.')) { + return `${arnPrefix(region)}:bedrock-agentcore:::evaluator/${evaluator}`; + } + // Look up custom evaluator from deployed state + for (const target of Object.values(deployedState.targets)) { + const evalState = target.resources?.evaluators?.[evaluator]; + if (evalState) return evalState.evaluatorArn; + } + return undefined; +} + +/** Extract a 12-digit account id from an ARN, or '*' if not present. */ +export function extractAccountIdFromArn(arn: string): string { + const parts = arn.split(':'); + return parts[4] && /^\d{12}$/.test(parts[4]) ? parts[4] : '*'; +} + +/** Resolve a config-bundle component key ({{runtime:...}} / {{gateway:...}}) to a real ARN for JSONPath. */ +export function resolveComponentKeyForJsonPath(key: string, deployedState: DeployedState): string { + if (key.startsWith('arn:')) return key; + + const rtMatch = /^\{\{runtime:(.+)\}\}$/.exec(key); + if (rtMatch) { + const rtName = rtMatch[1]!; + for (const target of Object.values(deployedState.targets)) { + const rt = target.resources?.runtimes?.[rtName]; + if (rt) return rt.runtimeArn; + } + } + + const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(key); + if (gwMatch) { + const gwName = gwMatch[1]!; + for (const target of Object.values(deployedState.targets)) { + const httpGw = target.resources?.gateways?.[gwName]; + if (httpGw) return httpGw.gatewayArn; + const mcpGw = target.resources?.mcp?.gateways?.[gwName]; + if (mcpGw) return mcpGw.gatewayArn; + } + } + + return key; +} + +/** Flatten statusReasons + result errorCode/errorMessage into a single display string (FAILED only). */ +export function extractFailureDetails(pollResult: { + statusReasons?: string[]; + recommendationResult?: RecommendationResult; +}): string | undefined { + const parts: string[] = []; + + if (pollResult.statusReasons?.length) { + parts.push(pollResult.statusReasons.join('; ')); + } + + const result = pollResult.recommendationResult; + if (result) { + const errorSource = result.systemPromptRecommendationResult ?? result.toolDescriptionRecommendationResult; + if (errorSource) { + if (errorSource.errorCode) parts.push(`[${errorSource.errorCode}]`); + if (errorSource.errorMessage) parts.push(errorSource.errorMessage); + } + } + + return parts.length > 0 ? parts.join(' ') : undefined; +} + +export interface BuildConfigOptions { + type: RecommendationType; + inlineContent?: string; + bundleArn?: string; + bundleVersion?: string; + systemPromptJsonPath?: string; + toolDescJsonPaths?: { toolName: string; toolDescriptionJsonPath: string }[]; + inputSource: string; + tools?: string[]; + traceSource: string; + lookbackDays?: number; + sessionIds?: string[]; + spansFile?: string; + fromInsights?: string; + batchEvaluationArn?: string; + runtimeId: string; + accountId: string; + region: string; + evaluatorIds: string[]; + onProgress?: (status: string, message: string) => void; + logger?: ExecLogger; +} + +/** + * Build the recommendationConfig request body. For traceSource 'sessions'/'spans-file' this performs + * the (slow, can-throw) client-side span fetch/read before returning — that work stays part of building + * the request, surfaced via onProgress, and throws on empty so the handler returns {success:false}. + */ +export async function buildRecommendationConfig(opts: BuildConfigOptions): Promise { + // Build agent traces — batch evaluation source, spans file, sessions, or CloudWatch + let agentTraces; + + if (opts.traceSource === 'batch-evaluation') { + let batchEvalArn: string; + if (opts.batchEvaluationArn) { + batchEvalArn = opts.batchEvaluationArn; + } else if (opts.fromInsights) { + const { loadRecord } = await import('../shared/storage'); + const record = loadRecord('insights', opts.fromInsights); + if (!record) { + throw new Error(`Insights run "${opts.fromInsights}" not found.`); + } + if (record.status !== 'COMPLETED' && record.status !== 'COMPLETED_WITH_ERRORS') { + throw new Error( + `Insights run "${opts.fromInsights}" has status ${record.status}. Only COMPLETED runs can be used as recommendation source.` + ); + } + batchEvalArn = record.arn; + } else { + throw new Error( + 'Either --from-insights or --batch-evaluation-arn is required for batch-evaluation trace source.' + ); + } + agentTraces = { batchEvaluation: { batchEvaluationArn: batchEvalArn } }; + } else if (opts.traceSource === 'spans-file' && opts.spansFile) { + // Explicit spans file — read and use as inline sessionSpans + const spansContent = readFileSync(opts.spansFile, 'utf-8'); + const sessionSpans = JSON.parse(spansContent) as SessionSpan | SessionSpan[]; + const spansList = Array.isArray(sessionSpans) ? sessionSpans : [sessionSpans]; + if (spansList.length > MAX_INLINE_SPANS) { + throw new ValidationError( + `Spans file contains ${spansList.length} spans, which exceeds the maximum of ${MAX_INLINE_SPANS}. Reduce the number of spans or use CloudWatch-based trace collection instead.` + ); + } + agentTraces = { sessionSpans: spansList }; + } else if (opts.traceSource === 'sessions' && opts.sessionIds && opts.sessionIds.length > 0) { + // Session IDs selected — auto-fetch from both log groups and use inline sessionSpans. + // The CloudWatch trace config does not support filtering by multiple session IDs, + // so we fetch spans client-side and send them inline. + opts.onProgress?.('fetching-spans', 'Fetching session spans from CloudWatch...'); + opts.logger?.log( + 'Auto-fetching spans for selected sessions (CloudWatch config does not support session ID filtering)' + ); + + const allSpans = []; + for (const sessionId of opts.sessionIds) { + const result = await fetchSessionSpans({ + region: opts.region, + runtimeId: opts.runtimeId, + sessionId, + lookbackDays: opts.lookbackDays ?? 7, + onProgress: msg => { + opts.logger?.log(msg); + opts.onProgress?.('fetching-spans', msg); + }, + }); + allSpans.push(...result.spans); + } + + if (allSpans.length === 0) { + throw new Error( + 'No spans found for the specified session(s). Ensure the agent has been invoked and traces have propagated to CloudWatch (may take 5-10 minutes).' + ); + } + if (allSpans.length > MAX_INLINE_SPANS) { + throw new ValidationError( + `Fetched ${allSpans.length} spans across the specified sessions, which exceeds the maximum of ${MAX_INLINE_SPANS}. Reduce the number of sessions or use CloudWatch-based trace collection instead.` + ); + } + + opts.logger?.log(`Total spans fetched: ${allSpans.length}`); + opts.onProgress?.('fetching-spans', `Fetched ${allSpans.length} spans`); + agentTraces = { sessionSpans: allSpans }; + } else { + // Lookback-based path — use cloudwatchLogs with time range + const runtimeLogGroupArn = `${arnPrefix(opts.region)}:logs:${opts.region}:${opts.accountId}:log-group:${runtimeLogGroup(opts.runtimeId)}`; + const spansLogGroupArn = `${arnPrefix(opts.region)}:logs:${opts.region}:${opts.accountId}:log-group:aws/spans`; + + // Derive service name: strip the random hash suffix from runtimeId + // runtimeId format: {project}_{agent}-{hash} → serviceName: {project}_{agent}.DEFAULT + const serviceName = opts.runtimeId.replace(/-[^-]+$/, '.DEFAULT'); + + const lookbackDays = opts.lookbackDays ?? 7; + agentTraces = { + cloudwatchLogs: { + logGroupArns: [runtimeLogGroupArn, spansLogGroupArn], + serviceNames: [serviceName], + startTime: new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString(), + endTime: new Date().toISOString(), + }, + }; + } + + const evaluationConfig: RecommendationEvaluationConfig = { + evaluators: [{ evaluatorArn: opts.evaluatorIds[0]! }], + }; + + // Validate required fields for config-bundle source (API requires all three) + if (opts.inputSource === 'config-bundle' && opts.bundleArn && !opts.bundleVersion) { + throw new Error('Config bundle version is required. Provide --bundle-version or deploy the bundle first.'); + } + + if (opts.inputSource === 'config-bundle' && opts.bundleArn) { + if (opts.type === 'SYSTEM_PROMPT_RECOMMENDATION' && !opts.systemPromptJsonPath) { + throw new Error( + 'Config bundle requires --system-prompt-json-path to locate the system prompt field.\n' + + "Use the field name (e.g. --system-prompt-json-path 'systemPrompt') and it will be resolved from agentcore.json.\n" + + "Or provide the full JSONPath (e.g. '$.ARN.configuration.systemPrompt')." + ); + } + if (opts.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && !opts.toolDescJsonPaths?.length) { + throw new Error( + 'Config bundle requires --tool-desc-json-path to locate tool description fields.\n' + + "Example: --tool-desc-json-path 'toolName:$.ARN.configuration.toolDescription'" + ); + } + } + + if (opts.type === 'SYSTEM_PROMPT_RECOMMENDATION') { + return { + systemPromptRecommendationConfig: { + systemPrompt: + opts.inputSource === 'config-bundle' && opts.bundleArn + ? { + configurationBundle: { + bundleArn: opts.bundleArn, + versionId: opts.bundleVersion!, + systemPromptJsonPath: opts.systemPromptJsonPath, + }, + } + : { text: opts.inlineContent ?? '' }, + agentTraces, + evaluationConfig, + }, + }; + } + + // TOOL_DESCRIPTION_RECOMMENDATION + if (opts.inputSource === 'config-bundle' && opts.bundleArn && opts.toolDescJsonPaths?.length) { + // Config bundle source — pass bundle reference with JSON paths for server-side resolution + return { + toolDescriptionRecommendationConfig: { + toolDescription: { + configurationBundle: { + bundleArn: opts.bundleArn, + versionId: opts.bundleVersion!, + tools: opts.toolDescJsonPaths, + }, + }, + agentTraces, + }, + }; + } + + // Inline/file source — parse "toolName:description" pairs from tools array + const toolEntries = (opts.tools ?? []).map(t => { + const colonIdx = t.indexOf(':'); + const toolName = colonIdx > 0 ? t.slice(0, colonIdx) : t; + if (!TOOL_NAME_REGEX.test(toolName) || toolName.length > MAX_TOOL_NAME_LENGTH) { + throw new ValidationError( + `Tool name "${toolName}" is invalid. Must contain only alphanumeric characters, underscores, hyphens, or dots (max ${MAX_TOOL_NAME_LENGTH} chars).` + ); + } + if (colonIdx > 0) { + return { toolName, toolDescription: { text: t.slice(colonIdx + 1) } }; + } + return { toolName, toolDescription: { text: opts.inlineContent ?? '' } }; + }); + + return { + toolDescriptionRecommendationConfig: { + toolDescription: { + toolDescriptionText: { + tools: toolEntries, + }, + }, + agentTraces, + }, + }; +} diff --git a/src/cli/operations/recommendation/fetch-session-spans.ts b/src/cli/operations/jobs/recommendation/fetch-session-spans.ts similarity index 97% rename from src/cli/operations/recommendation/fetch-session-spans.ts rename to src/cli/operations/jobs/recommendation/fetch-session-spans.ts index a97dcc9c7..65c1050df 100644 --- a/src/cli/operations/recommendation/fetch-session-spans.ts +++ b/src/cli/operations/jobs/recommendation/fetch-session-spans.ts @@ -11,8 +11,8 @@ * * Without log records the mapper produces "zero trajectories". */ -import type { SessionSpan } from '../../aws/agentcore-recommendation'; -import { runtimeLogGroup, searchLogs } from '../../aws/cloudwatch'; +import type { SessionSpan } from '../../../aws/agentcore-recommendation'; +import { runtimeLogGroup, searchLogs } from '../../../aws/cloudwatch'; export interface FetchSessionSpansOptions { /** AWS region */ diff --git a/src/cli/operations/jobs/recommendation/format.ts b/src/cli/operations/jobs/recommendation/format.ts new file mode 100644 index 000000000..6c6afc2fd --- /dev/null +++ b/src/cli/operations/jobs/recommendation/format.ts @@ -0,0 +1,63 @@ +/** Presentation helpers for recommendation job CLI output (history table + detail view). */ +import { formatJobDate } from '../shared/format'; +import type { RecommendationJobRecord } from '../shared/types'; + +function shortType(type: string): string { + if (type === 'SYSTEM_PROMPT_RECOMMENDATION') return 'System Prompt'; + if (type === 'TOOL_DESCRIPTION_RECOMMENDATION') return 'Tool Description'; + return type; +} + +export function printRecommendationHistory(records: RecommendationJobRecord[]): void { + if (records.length === 0) { + console.log('No recommendation jobs found. Run `agentcore run recommendation` to create one.'); + return; + } + console.log(`\n${'Date'.padEnd(22)} ${'Status'.padEnd(14)} ${'Type'.padEnd(18)} ${'Agent'.padEnd(18)} ${'ID'}`); + console.log('─'.repeat(100)); + for (const r of records) { + console.log( + `${formatJobDate(r.createdAt).padEnd(22)} ${r.status.padEnd(14)} ${shortType(r.recommendationType).padEnd(18)} ${(r.agent ?? 'unknown').padEnd(18)} ${r.id}` + ); + } + console.log(''); +} + +export function printRecommendationDetail(record: RecommendationJobRecord): void { + console.log(`\nRecommendation: ${record.id}`); + console.log(`Status: ${record.status}`); + console.log(`Agent: ${record.agent}`); + console.log(`Type: ${shortType(record.recommendationType)}`); + console.log(`Evaluators: ${record.evaluators.join(', ') || '(none)'}`); + console.log(`Started: ${formatJobDate(record.createdAt)}`); + if (record.completedAt) console.log(`Completed: ${formatJobDate(record.completedAt)}`); + if (record.kmsKeyArn) console.log(`KMS Key: ${record.kmsKeyArn}`); + + const sys = record.result?.systemPromptRecommendationResult; + const tool = record.result?.toolDescriptionRecommendationResult; + if (sys?.recommendedSystemPrompt) { + console.log('\n+++ Recommended System Prompt +++'); + console.log(sys.recommendedSystemPrompt); + if (sys.explanation) { + console.log('\n--- Explanation ---'); + console.log(sys.explanation); + } + } else if (tool?.tools?.length) { + for (const t of tool.tools) { + console.log(`\nTool: ${t.toolName}`); + console.log(`Recommended: ${t.recommendedToolDescription}`); + if (t.explanation) { + console.log(`Explanation: ${t.explanation}`); + } + } + } else if (record.status === 'FAILED') { + console.log(`\nError: ${record.failureDetail ?? record.statusReasons?.join('; ') ?? 'unknown'}`); + } else { + console.log('\nResult not yet available.'); + } + if (record.syncedVersionId) { + console.log(`\nNew config bundle version ${record.syncedVersionId} applied to agentcore.json.`); + } + if (record.logFilePath) console.log(`\nLog: ${record.logFilePath}`); + console.log(''); +} diff --git a/src/cli/operations/jobs/recommendation/handler.ts b/src/cli/operations/jobs/recommendation/handler.ts new file mode 100644 index 000000000..8889b86df --- /dev/null +++ b/src/cli/operations/jobs/recommendation/handler.ts @@ -0,0 +1,336 @@ +/** + * Recommendation job handler — composes Startable, Refreshable, Settles, Archivable. + * + * - create(): resolve agent + evaluator(s), build the recommendationConfig (incl. the slow + * sessions/spans-file fetch), make ONE StartRecommendation call, persist the record. + * - refresh(): GET latest status; map 404 → NOT_FOUND; copy result / failure detail. Pure (no config writes). + * - settle(): once COMPLETED for a config-bundle input, sync the new bundle version into agentcore.json + * exactly once (idempotent via syncedVersionId). Runs sequentially in the engine. + * - archive(): DeleteRecommendation (recommendation has no Stop — archive is the cancel). + */ +import { ConfigIO, JobNotFoundError, ResourceNotFoundError, ValidationError, toError } from '../../../../lib'; +import type { Result } from '../../../../lib/result'; +import { deleteRecommendation, getRecommendation, startRecommendation } from '../../../aws/agentcore-recommendation'; +import { detectRegion } from '../../../aws/region'; +import { ExecLogger } from '../../../logging/exec-logger'; +import { + MAX_INLINE_TEXT_LENGTH, + NOT_FOUND_STATUS, + RECOMMENDATION_NAME_REGEX, + TERMINAL_STATUSES, +} from '../shared/constants'; +import { regionFromArn, resolveJobRegion } from '../shared/region'; +import { resolveAgentState } from '../shared/resolve-agent-state'; +import type { RecommendationHandler, RecommendationJobRecord, StartRecommendationJobOptions } from '../shared/types'; +import { applyRecommendationToBundle } from './apply-to-bundle'; +import { + buildRecommendationConfig, + extractAccountIdFromArn, + extractFailureDetails, + resolveComponentKeyForJsonPath, + resolveEvaluatorId, +} from './build-config'; +import { readFileSync } from 'fs'; + +/** Max length the service allows for a recommendation name (RECOMMENDATION_NAME_REGEX: 48 chars). */ +const MAX_RECOMMENDATION_NAME_LENGTH = 48; + +/** + * Build an auto-generated recommendation name that satisfies the service constraint + * (RECOMMENDATION_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]{0,47}$/). The `_` prefix is + * sanitized (invalid chars → `_`) and truncated so that `_` stays within 48 + * chars; the full timestamp is kept as the suffix to preserve uniqueness across runs. + */ +export function autoRecommendationName( + projectName: string, + agent: string | undefined, + now: number = Date.now() +): string { + const suffix = String(now); + const maxPrefix = MAX_RECOMMENDATION_NAME_LENGTH - suffix.length - 1; // 1 for the joining "_" + const rawPrefix = agent ? `${projectName}_${agent}` : projectName; + let prefix = rawPrefix.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, Math.max(1, maxPrefix)); + // Must begin with a letter; if truncation/sanitization left a non-letter lead, prefix with 'r'. + if (!/^[a-zA-Z]/.test(prefix)) prefix = `r${prefix}`.slice(0, Math.max(1, maxPrefix)); + return `${prefix}_${suffix}`; +} + +export const recommendationHandler: RecommendationHandler = { + async create( + opts: StartRecommendationJobOptions, + configIO: ConfigIO + ): Promise> { + let logger: ExecLogger | undefined; + try { + logger = new ExecLogger({ command: 'recommend' }); + } catch { + // Logger creation can fail in tests or with no project root — non-fatal. + } + + try { + logger?.startStep('Load project config'); + const [projectSpec, deployedState, awsTargets] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readDeployedState(), + configIO.resolveAWSDeploymentTargets(), + ]); + const region = await resolveJobRegion(opts.region, awsTargets); + logger?.log(`Region: ${region}`); + logger?.endStep('success'); + + // Resolve agent (needed for runtimeId + account id from its ARN) — skip for batch-evaluation source + logger?.startStep('Resolve agent and evaluators'); + const agentState = opts.agent ? resolveAgentState(deployedState, opts.agent) : undefined; + if (!agentState && opts.traceSource !== 'batch-evaluation') { + const err = new ResourceNotFoundError(`Agent "${opts.agent}" not deployed. Run \`agentcore deploy\` first.`); + logger?.endStep('error', err.message); + logger?.finalize(false); + return { success: false, error: err }; + } + + // Resolve evaluators (arity enforced here, not at the command layer) + const evaluatorIds: string[] = []; + for (const evaluator of opts.evaluators) { + const id = resolveEvaluatorId(deployedState, evaluator, region); + if (!id) { + const err = new ResourceNotFoundError( + `Evaluator "${evaluator}" not found. Use a Builtin.* name, a full ARN, or deploy a custom evaluator first.` + ); + logger?.endStep('error', err.message); + logger?.finalize(false); + return { success: false, error: err }; + } + evaluatorIds.push(id); + } + if (opts.type === 'SYSTEM_PROMPT_RECOMMENDATION' && evaluatorIds.length !== 1) { + const err = new ValidationError('System prompt recommendations require exactly one evaluator.'); + logger?.endStep('error', err.message); + logger?.finalize(false); + return { success: false, error: err }; + } + logger?.log(`Evaluators: ${evaluatorIds.join(', ') || '(none)'}`); + logger?.endStep('success'); + + // Read inline/file content + validate non-empty system-prompt before any API call + let inlineContent: string | undefined; + if (opts.inputSource === 'file' && opts.promptFile) { + inlineContent = readFileSync(opts.promptFile, 'utf-8'); + } else if (opts.inputSource === 'inline') { + inlineContent = opts.inlineContent; + } + if ( + opts.type === 'SYSTEM_PROMPT_RECOMMENDATION' && + opts.inputSource !== 'config-bundle' && + !inlineContent?.trim() + ) { + const err = new ValidationError( + 'System prompt content is required. Provide via --inline, --prompt-file, or --bundle-name.' + ); + logger?.finalize(false); + return { success: false, error: err }; + } + if (inlineContent && inlineContent.length > MAX_INLINE_TEXT_LENGTH) { + const err = new ValidationError( + `Inline text exceeds the maximum allowed length (${inlineContent.length} characters, limit is ${MAX_INLINE_TEXT_LENGTH}).` + ); + logger?.finalize(false); + return { success: false, error: err }; + } + + const accountId = agentState ? extractAccountIdFromArn(agentState.runtimeArn) : ''; + + // Resolve config-bundle ARN + short JSONPath (from deployed state / agentcore.json) + let bundleArn: string | undefined; + let resolvedSystemPromptJsonPath = opts.systemPromptJsonPath; + if (opts.inputSource === 'config-bundle' && opts.bundleName) { + if (opts.bundleName.startsWith('arn:')) { + bundleArn = opts.bundleName; + } else { + for (const target of Object.values(deployedState.targets ?? {})) { + const bundle = target?.resources?.configBundles?.[opts.bundleName]; + if (bundle?.bundleArn) { + bundleArn = bundle.bundleArn; + break; + } + } + if (!bundleArn) { + const err = new ResourceNotFoundError( + `Config bundle "${opts.bundleName}" not found in deployed state. Run \`agentcore deploy\` first.` + ); + logger?.finalize(false); + return { success: false, error: err }; + } + } + + if (resolvedSystemPromptJsonPath && !resolvedSystemPromptJsonPath.startsWith('$')) { + const bundleName = opts.bundleName.startsWith('arn:') + ? Object.values(deployedState.targets) + .flatMap(t => Object.entries(t.resources?.configBundles ?? {})) + .find(([, b]) => b.bundleArn === opts.bundleName)?.[0] + : opts.bundleName; + if (bundleName) { + const projBundle = projectSpec.configBundles?.find(b => b.name === bundleName); + if (projBundle?.components) { + const firstComponentKey = Object.keys(projBundle.components)[0]; + if (firstComponentKey) { + const resolvedKey = resolveComponentKeyForJsonPath(firstComponentKey, deployedState); + resolvedSystemPromptJsonPath = `$.${resolvedKey}.configuration.${resolvedSystemPromptJsonPath}`; + } + } + } + } + } + + // Build the request body (this performs the sessions/spans-file fetch when applicable) + const recommendationConfig = await buildRecommendationConfig({ + type: opts.type, + inlineContent, + bundleArn, + bundleVersion: opts.bundleVersion, + systemPromptJsonPath: resolvedSystemPromptJsonPath, + toolDescJsonPaths: opts.toolDescJsonPaths, + inputSource: opts.inputSource, + tools: opts.tools, + traceSource: opts.traceSource, + lookbackDays: opts.lookbackDays, + sessionIds: opts.sessionIds, + spansFile: opts.spansFile, + fromInsights: opts.fromInsights, + batchEvaluationArn: opts.batchEvaluationArn, + runtimeId: agentState?.runtimeId ?? '', + accountId, + region, + evaluatorIds, + onProgress: opts.onProgress, + logger, + }); + + // ONE API call + logger?.startStep('Start recommendation'); + const name = opts.recommendationName ?? autoRecommendationName(projectSpec.name, opts.agent); + if (opts.recommendationName && !RECOMMENDATION_NAME_REGEX.test(opts.recommendationName)) { + const err = new ValidationError( + `Recommendation name "${opts.recommendationName}" is invalid. Must begin with a letter and contain only alphanumeric characters, underscores, or hyphens (max 48 chars).` + ); + logger?.endStep('error', err.message); + logger?.finalize(false); + return { success: false, error: err }; + } + opts.onProgress?.('starting', `Starting recommendation "${name}"...`); + const startResult = await startRecommendation({ + region, + name, + type: opts.type, + recommendationConfig, + ...(opts.kmsKeyArn ? { kmsKeyArn: opts.kmsKeyArn } : {}), + }); + logger?.log(`Response: ${JSON.stringify(startResult, null, 2)}`); + logger?.endStep('success'); + opts.onProgress?.('started', `Recommendation created: ${startResult.recommendationId} (${startResult.status})`); + logger?.finalize(true); + + const record: RecommendationJobRecord = { + type: 'recommendation', + id: startResult.recommendationId, + arn: startResult.recommendationArn, + status: startResult.status, + createdAt: startResult.createdAt ?? new Date().toISOString(), + agent: opts.agent ?? '', + logFilePath: logger?.logFilePath, + recommendationType: opts.type, + evaluators: opts.evaluators, + inputSource: opts.inputSource, + bundleName: opts.bundleName, + bundleArn, + bundleVersion: opts.bundleVersion, + systemPromptJsonPath: resolvedSystemPromptJsonPath, + toolDescJsonPaths: opts.toolDescJsonPaths, + ...(opts.kmsKeyArn ? { kmsKeyArn: opts.kmsKeyArn } : {}), + }; + return { success: true, record }; + } catch (err) { + logger?.finalize(false); + return { success: false, error: toError(err) }; + } + }, + + async refresh(record: RecommendationJobRecord): Promise> { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + let response; + try { + response = await getRecommendation({ region, recommendationId: record.id }); + } catch (err) { + if (err instanceof JobNotFoundError) { + return { success: true, record: { ...record, status: NOT_FOUND_STATUS } }; + } + return { success: false, error: toError(err) }; + } + + const failureDetail = + response.status === 'FAILED' + ? extractFailureDetails({ + statusReasons: response.statusReasons, + recommendationResult: response.recommendationResult, + }) + : undefined; + + // `completedAt` must only be set once the job actually reaches a terminal state — the UI renders a + // "Completed:" line whenever it is present. The service's `updatedAt` advances on every state + // transition (incl. PENDING → IN_PROGRESS), so it is NOT a completion signal. For a non-terminal + // status we leave `completedAt` unset (clearing any value a prior buggy refresh may have stored), + // so a still-running job never renders as completed. + const isTerminalStatus = TERMINAL_STATUSES.recommendation.has(response.status); + const completedAt = isTerminalStatus + ? (response.completedAt ?? response.updatedAt ?? record.completedAt) + : undefined; + + return { + success: true, + record: { + ...record, + status: response.status, + completedAt, + result: response.recommendationResult ?? record.result, + statusReasons: response.statusReasons ?? record.statusReasons, + failureDetail: failureDetail ?? record.failureDetail, + }, + }; + }, + + async settle(record: RecommendationJobRecord, configIO: ConfigIO): Promise { + // Only config-bundle recommendations that completed and produced a new bundle version, once. + if (record.inputSource !== 'config-bundle' || record.status !== 'COMPLETED' || !record.result) { + return record; + } + const resultBundle = + record.result.systemPromptRecommendationResult?.configurationBundle ?? + record.result.toolDescriptionRecommendationResult?.configurationBundle; + if (!resultBundle || record.syncedVersionId === resultBundle.versionId) { + return record; + } + + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + const applyResult = await applyRecommendationToBundle( + { bundleName: record.bundleName, bundleArn: record.bundleArn, result: record.result, region }, + configIO + ); + if (applyResult.success) { + return { ...record, syncedVersionId: resultBundle.versionId }; + } + return record; // leave unsynced so a later get()/list() retries + }, + + async archive(record: RecommendationJobRecord): Promise { + const region = regionFromArn(record.arn) ?? (await detectRegion()).region; + try { + await deleteRecommendation({ region, recommendationId: record.id }); + return { success: true }; + } catch (err) { + // Already gone on the service — local cleanup can still proceed. + if (err instanceof JobNotFoundError) { + return { success: true }; + } + return { success: false, error: toError(err) }; + } + }, +}; diff --git a/src/cli/operations/jobs/shared/__tests__/constants.test.ts b/src/cli/operations/jobs/shared/__tests__/constants.test.ts new file mode 100644 index 000000000..0148b8f10 --- /dev/null +++ b/src/cli/operations/jobs/shared/__tests__/constants.test.ts @@ -0,0 +1,93 @@ +import { NOT_FOUND_STATUS, isTerminal } from '../constants'; +import type { ABTestJobRecord, BatchEvaluationJobRecord, RecommendationJobRecord } from '../types'; +import { describe, expect, it } from 'vitest'; + +function rec(status: string): RecommendationJobRecord { + return { + type: 'recommendation', + id: 'rec-1', + arn: 'arn', + status, + createdAt: '2026-06-01T00:00:00Z', + agent: 'a', + recommendationType: 'SYSTEM_PROMPT_RECOMMENDATION', + evaluators: [], + inputSource: 'inline', + }; +} + +function batch(status: string, resultsFetched?: boolean): BatchEvaluationJobRecord { + return { + type: 'batch-evaluation', + id: 'be-1', + arn: 'arn', + status, + createdAt: '2026-06-01T00:00:00Z', + agent: 'a', + name: 'n', + evaluators: [], + resultsFetched, + }; +} + +/** status = lifecycle (ACTIVE/FAILED/CREATE_FAILED), lifecycleStatus = executionStatus (RUNNING/PAUSED/STOPPED) */ +function abTest(status: string, lifecycleStatus = 'RUNNING'): ABTestJobRecord { + return { + type: 'ab-test', + id: 'abt-1', + arn: 'arn', + status, + lifecycleStatus, + createdAt: '2026-06-01T00:00:00Z', + agent: 'a', + name: 'n', + mode: 'config-bundle', + gatewayArn: 'arn:aws:bedrock-agentcore:us-east-1:1:gateway/g', + variants: [], + evaluationConfig: { onlineEvaluationConfigArn: 'arn' }, + }; +} + +describe('isTerminal', () => { + it('treats recommendation COMPLETED/FAILED/NOT_FOUND as terminal', () => { + expect(isTerminal(rec('COMPLETED'))).toBe(true); + expect(isTerminal(rec('FAILED'))).toBe(true); + expect(isTerminal(rec(NOT_FOUND_STATUS))).toBe(true); + }); + + it('treats recommendation PENDING/IN_PROGRESS as non-terminal', () => { + expect(isTerminal(rec('PENDING'))).toBe(false); + expect(isTerminal(rec('IN_PROGRESS'))).toBe(false); + }); + + it('keeps a terminal-status batch eval refreshable until results are fetched', () => { + expect(isTerminal(batch('COMPLETED', false))).toBe(false); // results not fetched yet → still refreshable + expect(isTerminal(batch('COMPLETED', true))).toBe(true); + }); + + it('treats batch NOT_FOUND as terminal regardless of resultsFetched', () => { + expect(isTerminal(batch(NOT_FOUND_STATUS, false))).toBe(true); + }); + + it('treats unknown statuses as non-terminal', () => { + expect(isTerminal(rec('SOMETHING_NEW'))).toBe(false); + expect(isTerminal(batch('STOPPING', true))).toBe(false); + }); + + it('treats ab-test as terminal when failed + failureReason captured, or NOT_FOUND', () => { + expect(isTerminal({ ...abTest('FAILED'), failureReason: 'infra error' })).toBe(true); + expect(isTerminal({ ...abTest('CREATE_FAILED'), failureReason: 'setup error' })).toBe(true); + expect(isTerminal({ ...abTest('UPDATE_FAILED'), failureReason: 'update error' })).toBe(true); + expect(isTerminal(abTest(NOT_FOUND_STATUS))).toBe(true); + }); + + it('keeps ab-test refreshable when failed but failureReason not yet captured', () => { + expect(isTerminal(abTest('FAILED'))).toBe(false); + expect(isTerminal(abTest('CREATE_FAILED'))).toBe(false); + }); + + it('treats ab-test ACTIVE/CREATING as non-terminal', () => { + expect(isTerminal(abTest('ACTIVE'))).toBe(false); + expect(isTerminal(abTest('CREATING'))).toBe(false); + }); +}); diff --git a/src/cli/operations/jobs/shared/__tests__/engine.test.ts b/src/cli/operations/jobs/shared/__tests__/engine.test.ts new file mode 100644 index 000000000..42a87b4cf --- /dev/null +++ b/src/cli/operations/jobs/shared/__tests__/engine.test.ts @@ -0,0 +1,248 @@ +import { createJobEngine } from '../engine'; +import type { BatchEvaluationJobRecord, RecommendationJobRecord } from '../types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mocks (hoisted so the vi.mock factories below can reference them) ───────── +const { store, recHandler, batchHandler, mockValidateCreds } = vi.hoisted(() => ({ + store: { saveRecord: vi.fn(), loadRecord: vi.fn(), listRecords: vi.fn(), deleteRecord: vi.fn() }, + recHandler: { create: vi.fn(), refresh: vi.fn(), settle: vi.fn(), archive: vi.fn() }, + batchHandler: { create: vi.fn(), refresh: vi.fn(), stop: vi.fn(), archive: vi.fn() }, + mockValidateCreds: vi.fn(), +})); + +vi.mock('../storage', () => ({ + saveRecord: (...a: unknown[]) => store.saveRecord(...a), + loadRecord: (...a: unknown[]) => store.loadRecord(...a), + listRecords: (...a: unknown[]) => store.listRecords(...a), + deleteRecord: (...a: unknown[]) => store.deleteRecord(...a), +})); +vi.mock('../../recommendation/handler', () => ({ recommendationHandler: recHandler })); +vi.mock('../../batch-evaluation/handler', () => ({ batchEvaluationHandler: batchHandler })); +vi.mock('../../../../aws/account', () => ({ validateAwsCredentials: (...a: unknown[]) => mockValidateCreds(...a) })); +vi.mock('../../../../../lib', () => ({ + ConfigIO: function () { + return {}; + }, + JobNotFoundError: class JobNotFoundError extends Error {}, +})); + +function recRecord(over: Partial = {}): RecommendationJobRecord { + return { + type: 'recommendation', + id: 'rec-1', + arn: 'arn:aws:bedrock-agentcore:us-west-2:111122223333:recommendation/rec-1', + status: 'PENDING', + createdAt: '2026-06-01T00:00:00Z', + agent: 'myagent', + recommendationType: 'SYSTEM_PROMPT_RECOMMENDATION', + evaluators: ['Builtin.Correctness'], + inputSource: 'inline', + ...over, + }; +} + +function batchRecord(over: Partial = {}): BatchEvaluationJobRecord { + return { + type: 'batch-evaluation', + id: 'be-1', + arn: 'arn:aws:bedrock-agentcore:us-west-2:111122223333:batch-evaluation/be-1', + status: 'IN_PROGRESS', + createdAt: '2026-06-01T00:00:00Z', + agent: 'myagent', + name: 'run1', + evaluators: ['Builtin.Correctness'], + ...over, + }; +} + +describe('createJobEngine', () => { + beforeEach(() => { + mockValidateCreds.mockResolvedValue(undefined); + recHandler.settle.mockImplementation((r: RecommendationJobRecord) => Promise.resolve(r)); + }); + afterEach(() => vi.clearAllMocks()); + + describe('start', () => { + it('validates credentials, calls handler.create, and saves the record', async () => { + const record = recRecord(); + recHandler.create.mockResolvedValue({ success: true, record }); + const engine = createJobEngine(); + + const result = await engine.start('recommendation', { + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'myagent', + evaluators: ['Builtin.Correctness'], + inputSource: 'inline', + traceSource: 'cloudwatch', + }); + + expect(mockValidateCreds).toHaveBeenCalled(); + expect(recHandler.create).toHaveBeenCalled(); + expect(store.saveRecord).toHaveBeenCalledWith(record); + expect(result.success).toBe(true); + }); + + it('does not save when credentials are invalid', async () => { + mockValidateCreds.mockRejectedValue(new Error('expired token')); + const engine = createJobEngine(); + const result = await engine.start('recommendation', { + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'a', + evaluators: [], + inputSource: 'inline', + traceSource: 'cloudwatch', + }); + expect(result.success).toBe(false); + expect(recHandler.create).not.toHaveBeenCalled(); + expect(store.saveRecord).not.toHaveBeenCalled(); + }); + + it('does not save when create fails', async () => { + recHandler.create.mockResolvedValue({ success: false, error: new Error('bad input') }); + const engine = createJobEngine(); + const result = await engine.start('recommendation', { + type: 'SYSTEM_PROMPT_RECOMMENDATION', + agent: 'a', + evaluators: ['Builtin.Correctness'], + inputSource: 'inline', + traceSource: 'cloudwatch', + }); + expect(result.success).toBe(false); + expect(store.saveRecord).not.toHaveBeenCalled(); + }); + }); + + describe('get', () => { + it('returns a terminal record without refreshing', async () => { + store.loadRecord.mockReturnValue(recRecord({ status: 'COMPLETED' })); + const engine = createJobEngine(); + const record = await engine.get('recommendation', 'rec-1'); + expect(record?.status).toBe('COMPLETED'); + expect(recHandler.refresh).not.toHaveBeenCalled(); + }); + + it('refreshes a non-terminal record and saves the update', async () => { + store.loadRecord.mockReturnValue(recRecord({ status: 'IN_PROGRESS' })); + recHandler.refresh.mockResolvedValue({ success: true, record: recRecord({ status: 'COMPLETED' }) }); + const engine = createJobEngine(); + const record = await engine.get('recommendation', 'rec-1'); + expect(recHandler.refresh).toHaveBeenCalled(); + expect(store.saveRecord).toHaveBeenCalled(); + expect(record?.status).toBe('COMPLETED'); + }); + + it('persists error after refresh retries exhausted', async () => { + store.loadRecord.mockReturnValue(recRecord({ status: 'IN_PROGRESS' })); + recHandler.refresh.mockResolvedValue({ success: false, error: new Error('transient network error') }); + const engine = createJobEngine(); + const record = await engine.get('recommendation', 'rec-1'); + expect(record?.error).toBe('transient network error'); + }); + + it('returns undefined for a missing record', async () => { + store.loadRecord.mockReturnValue(undefined); + const engine = createJobEngine(); + expect(await engine.get('recommendation', 'nope')).toBeUndefined(); + }); + + it('runs settle() after refresh for recommendations', async () => { + store.loadRecord.mockReturnValue(recRecord({ status: 'IN_PROGRESS' })); + const completed = recRecord({ status: 'COMPLETED' }); + recHandler.refresh.mockResolvedValue({ success: true, record: completed }); + recHandler.settle.mockResolvedValue(recRecord({ status: 'COMPLETED', syncedVersionId: 'v2' })); + const engine = createJobEngine(); + const record = await engine.get('recommendation', 'rec-1'); + expect(recHandler.settle).toHaveBeenCalled(); + expect(record!.syncedVersionId).toBe('v2'); + }); + }); + + describe('list', () => { + it('refreshes non-terminal records and sorts by createdAt desc', async () => { + store.listRecords.mockReturnValue([ + recRecord({ id: 'old', status: 'COMPLETED', createdAt: '2026-06-01T00:00:00Z' }), + recRecord({ id: 'new', status: 'COMPLETED', createdAt: '2026-06-03T00:00:00Z' }), + ]); + const engine = createJobEngine(); + const records = await engine.list({ type: 'recommendation' }); + expect(records.map(r => r.id)).toEqual(['new', 'old']); + }); + + it('persists error when refresh fails for a list item', async () => { + store.listRecords.mockReturnValue([ + recRecord({ id: 'a', status: 'IN_PROGRESS' }), + recRecord({ id: 'b', status: 'COMPLETED' }), + ]); + recHandler.refresh.mockResolvedValue({ success: false, error: new Error('boom') }); + const engine = createJobEngine(); + const records = await engine.list({ type: 'recommendation' }); + expect(records).toHaveLength(2); + expect(records.find(r => r.id === 'a')?.error).toBe('boom'); + }); + + it('applies limit', async () => { + store.listRecords.mockReturnValue([ + recRecord({ id: 'a', status: 'COMPLETED', createdAt: '2026-06-03T00:00:00Z' }), + recRecord({ id: 'b', status: 'COMPLETED', createdAt: '2026-06-02T00:00:00Z' }), + recRecord({ id: 'c', status: 'COMPLETED', createdAt: '2026-06-01T00:00:00Z' }), + ]); + const engine = createJobEngine(); + const records = await engine.list({ type: 'recommendation', limit: 2 }); + expect(records.map(r => r.id)).toEqual(['a', 'b']); + }); + }); + + describe('stop', () => { + it('calls the handler stop and records STOPPING on success', async () => { + store.loadRecord.mockReturnValue(batchRecord()); + batchHandler.stop.mockResolvedValue({ success: true }); + const engine = createJobEngine(); + const result = await engine.stop('batch-evaluation', 'be-1'); + expect(batchHandler.stop).toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(store.saveRecord).toHaveBeenCalledWith(expect.objectContaining({ status: 'STOPPING' })); + }); + + it('returns not-found for a missing record', async () => { + store.loadRecord.mockReturnValue(undefined); + const engine = createJobEngine(); + const result = await engine.stop('batch-evaluation', 'nope'); + expect(result.success).toBe(false); + }); + }); + + describe('archive', () => { + it('deletes the local record after a successful service delete', async () => { + store.loadRecord.mockReturnValue(recRecord()); + recHandler.archive.mockResolvedValue({ success: true }); + const engine = createJobEngine(); + const result = await engine.archive('recommendation', 'rec-1'); + expect(recHandler.archive).toHaveBeenCalled(); + expect(store.deleteRecord).toHaveBeenCalledWith('recommendation', 'rec-1'); + expect(result.success).toBe(true); + }); + + it('does not delete locally when the service delete fails', async () => { + store.loadRecord.mockReturnValue(recRecord()); + recHandler.archive.mockResolvedValue({ success: false, error: new Error('nope') }); + const engine = createJobEngine(); + const result = await engine.archive('recommendation', 'rec-1'); + expect(store.deleteRecord).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + }); + }); + + describe('capabilities', () => { + it('reports batch-evaluation as stoppable and recommendation as not', () => { + const engine = createJobEngine(); + expect(engine.capabilities('batch-evaluation').canStop).toBe(true); + expect(engine.capabilities('recommendation').canStop).toBe(false); + }); + + it('reports ab-test as stoppable, pausable, promotable, and debuggable', () => { + const engine = createJobEngine(); + const caps = engine.capabilities('ab-test'); + expect(caps).toEqual({ canStop: true, canPause: true, canPromote: true, canDebug: true }); + }); + }); +}); diff --git a/src/cli/operations/jobs/shared/__tests__/region.test.ts b/src/cli/operations/jobs/shared/__tests__/region.test.ts new file mode 100644 index 000000000..fad27d4db --- /dev/null +++ b/src/cli/operations/jobs/shared/__tests__/region.test.ts @@ -0,0 +1,41 @@ +import { regionFromArn, resolveJobRegion } from '../region'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockDetectRegion } = vi.hoisted(() => ({ mockDetectRegion: vi.fn() })); +vi.mock('../../../../aws/region', () => ({ detectRegion: () => mockDetectRegion() })); + +describe('regionFromArn', () => { + it('parses the region (field index 3) from a well-formed ARN', () => { + expect(regionFromArn('arn:aws:bedrock-agentcore:us-west-2:111122223333:recommendation/rec-1')).toBe('us-west-2'); + }); + + it('parses GovCloud / China partitions', () => { + expect(regionFromArn('arn:aws-us-gov:bedrock-agentcore:us-gov-west-1:111:recommendation/r')).toBe('us-gov-west-1'); + expect(regionFromArn('arn:aws-cn:bedrock-agentcore:cn-north-1:111:recommendation/r')).toBe('cn-north-1'); + }); + + it('returns undefined for a region-less / malformed ARN', () => { + expect(regionFromArn('not-an-arn')).toBeUndefined(); + expect(regionFromArn('arn:aws:svc::111:res')).toBeUndefined(); + }); +}); + +describe('resolveJobRegion', () => { + afterEach(() => vi.clearAllMocks()); + + it('prefers the explicit option', async () => { + expect(await resolveJobRegion('eu-west-1', [{ region: 'us-east-1' }])).toBe('eu-west-1'); + expect(mockDetectRegion).not.toHaveBeenCalled(); + }); + + it('falls back to the first deployment target', async () => { + expect(await resolveJobRegion(undefined, [{ region: 'ap-southeast-2' }])).toBe('ap-southeast-2'); + expect(mockDetectRegion).not.toHaveBeenCalled(); + }); + + it('falls back to detectRegion when no option or target', async () => { + mockDetectRegion.mockResolvedValue({ region: 'us-west-2' }); + expect(await resolveJobRegion(undefined, [])).toBe('us-west-2'); + expect(mockDetectRegion).toHaveBeenCalled(); + }); +}); diff --git a/src/cli/operations/jobs/shared/__tests__/storage.test.ts b/src/cli/operations/jobs/shared/__tests__/storage.test.ts new file mode 100644 index 000000000..9f66f7e6f --- /dev/null +++ b/src/cli/operations/jobs/shared/__tests__/storage.test.ts @@ -0,0 +1,102 @@ +import { assertSafeId, deleteRecord, listRecords, loadRecord, saveRecord } from '../storage'; +import type { BatchEvaluationJobRecord, RecommendationJobRecord } from '../types'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Hoisted holder so the vi.mock factory reads the current temp dir at call time. +const ctx = vi.hoisted(() => ({ root: '' })); + +vi.mock('../../../../../lib', () => ({ + CLI_SYSTEM_DIR: '.cli', + findConfigRoot: () => ctx.root, + NoProjectError: class NoProjectError extends Error {}, +})); + +function rec(over: Partial = {}): RecommendationJobRecord { + return { + type: 'recommendation', + id: 'rec-1', + arn: 'arn', + status: 'COMPLETED', + createdAt: '2026-06-01T00:00:00Z', + agent: 'a', + recommendationType: 'SYSTEM_PROMPT_RECOMMENDATION', + evaluators: [], + inputSource: 'inline', + ...over, + }; +} + +describe('jobs storage', () => { + beforeEach(() => { + ctx.root = mkdtempSync(join(tmpdir(), 'jobs-storage-')); + }); + afterEach(() => { + rmSync(ctx.root, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it('round-trips a record through save → load', () => { + const record = rec({ id: 'rec-abc' }); + saveRecord(record); + expect(loadRecord('recommendation', 'rec-abc')).toEqual(record); + }); + + it('returns undefined for a missing record', () => { + expect(loadRecord('recommendation', 'missing')).toBeUndefined(); + }); + + it('lists records of a type and skips corrupt files without throwing', () => { + saveRecord(rec({ id: 'good-1' })); + saveRecord(rec({ id: 'good-2' })); + // Drop a corrupt file into the same dir + writeFileSync(join(ctx.root, '.cli', 'jobs', 'recommendations', 'broken.json'), '{ not valid json'); + const records = listRecords('recommendation'); + expect(records.map(r => r.id).sort()).toEqual(['good-1', 'good-2']); + }); + + it('ignores legacy-shape files (missing/wrong type discriminator)', () => { + mkdirSync(join(ctx.root, '.cli', 'jobs', 'recommendations'), { recursive: true }); + // Legacy recommendation record: keyed on recommendationId, `type` holds a RecommendationType + writeFileSync( + join(ctx.root, '.cli', 'jobs', 'recommendations', 'rec-legacy.json'), + JSON.stringify({ recommendationId: 'rec-legacy', type: 'SYSTEM_PROMPT_RECOMMENDATION', status: 'COMPLETED' }) + ); + expect(loadRecord('recommendation', 'rec-legacy')).toBeUndefined(); + expect(listRecords('recommendation')).toEqual([]); + }); + + it('deletes a record and reports whether it existed', () => { + saveRecord(rec({ id: 'rec-del' })); + expect(deleteRecord('recommendation', 'rec-del')).toBe(true); + expect(deleteRecord('recommendation', 'rec-del')).toBe(false); + expect(loadRecord('recommendation', 'rec-del')).toBeUndefined(); + }); + + it('keeps recommendation and batch-evaluation records in separate directories', () => { + saveRecord(rec({ id: 'rec-1' })); + const be: BatchEvaluationJobRecord = { + type: 'batch-evaluation', + id: 'be-1', + arn: 'arn', + status: 'COMPLETED', + createdAt: '2026-06-01T00:00:00Z', + agent: 'a', + name: 'n', + evaluators: [], + resultsFetched: true, + }; + saveRecord(be); + expect(listRecords('recommendation').map(r => r.id)).toEqual(['rec-1']); + expect(listRecords('batch-evaluation').map(r => r.id)).toEqual(['be-1']); + }); + + it('rejects ids containing path separators', () => { + expect(() => assertSafeId('../escape')).toThrow(); + expect(() => assertSafeId('a/b')).toThrow(); + expect(() => assertSafeId('')).toThrow(); + expect(() => assertSafeId('safe-id_123')).not.toThrow(); + }); +}); diff --git a/src/cli/operations/jobs/shared/constants.ts b/src/cli/operations/jobs/shared/constants.ts new file mode 100644 index 000000000..b014bc7f9 --- /dev/null +++ b/src/cli/operations/jobs/shared/constants.ts @@ -0,0 +1,99 @@ +/** + * Job Engine constants: storage directory names, per-type terminal-status sets, + * capability flags, and shared validation patterns. + */ +import type { JobCapabilities, JobRecord, JobType } from './types'; + +/** + * Local storage directory per job type, under `/.cli/`. + * Reuses the existing directory names so the layout is unchanged. + */ +export const STORAGE_DIRS: Record = { + recommendation: 'recommendations', + 'batch-evaluation': 'batch-eval-results', + 'ab-test': 'ab-tests', + insights: 'insights', +}; + +/** Human-readable label per job type, for user-facing messages (e.g. "not found" errors). */ +export const JOB_TYPE_LABELS: Record = { + recommendation: 'Recommendation', + 'batch-evaluation': 'Batch evaluation', + 'ab-test': 'A/B test', + insights: 'Insights job', +}; + +/** Sentinel status set when a refresh GET 404s (job deleted on the service). Terminal for both types. */ +export const NOT_FOUND_STATUS = 'NOT_FOUND'; + +/** + * Terminal statuses per job type. The two services emit different vocabularies, so terminality + * is per-type — a single shared set would invent statuses neither service emits. + * `SUCCEEDED`/`DELETING` are kept defensively for recommendations (`COMPLETED`/`FAILED` are authoritative). + */ +export const TERMINAL_STATUSES: Record> = { + recommendation: new Set(['COMPLETED', 'FAILED', 'SUCCEEDED', 'DELETING', NOT_FOUND_STATUS]), + 'batch-evaluation': new Set([ + 'COMPLETED', + 'COMPLETED_WITH_ERRORS', + 'FAILED', + 'STOPPED', + 'CANCELLED', + NOT_FOUND_STATUS, + ]), + // AB test: `record.status` holds lifecycle status (ACTIVE/FAILED/CREATE_FAILED). + // `record.lifecycleStatus` holds executionStatus (RUNNING/PAUSED/STOPPED) for keybindings. + 'ab-test': new Set(['FAILED', 'CREATE_FAILED', 'UPDATE_FAILED', 'DELETE_FAILED', NOT_FOUND_STATUS]), + insights: new Set(['COMPLETED', 'COMPLETED_WITH_ERRORS', 'FAILED', 'STOPPED', NOT_FOUND_STATUS]), +}; + +/** Runtime capability flags (TUI display only; engine legality is enforced by types). */ +export const JOB_CAPABILITIES: Record = { + recommendation: { canStop: false, canPause: false, canPromote: false, canDebug: false }, + 'batch-evaluation': { canStop: true, canPause: false, canPromote: false, canDebug: false }, + 'ab-test': { canStop: true, canPause: true, canPromote: true, canDebug: true }, + insights: { canStop: false, canPause: false, canPromote: false, canDebug: false }, +}; + +/** Maximum character length for inline text input (system prompt or tool descriptions). */ +export const MAX_INLINE_TEXT_LENGTH = 20_000; + +/** Recommendation name rule: start with a letter, alphanumeric + underscore/hyphen, max 48 chars. */ +export const RECOMMENDATION_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]{0,47}$/; + +/** Maximum number of inline spans the API accepts. */ +export const MAX_INLINE_SPANS = 1000; + +/** Recommendation tool name rule: alphanumeric + underscore/hyphen/dot, max 256 chars. */ +export const TOOL_NAME_REGEX = /^[a-zA-Z0-9_\-.]+$/; +export const MAX_TOOL_NAME_LENGTH = 256; + +/** Batch-evaluation name rule: start with a letter, then letters/digits/underscores, max 48 chars. */ +export const BATCH_EVAL_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; + +/** + * Whether a record is in a terminal state AND fully settled (no further work on read). + * + * Batch-evaluation has a special case: a terminal record whose per-session results have not yet + * been fetched from CloudWatch is treated as NOT-yet-settled, so the next get()/list() retries + * the fetch (the output log can lag the status flip). + */ +export function isTerminal(record: JobRecord): boolean { + if (record.error) { + return true; // refresh retries exhausted — settled with an error + } + if (!TERMINAL_STATUSES[record.type].has(record.status)) { + return false; + } + if (record.type === 'batch-evaluation' && record.status !== NOT_FOUND_STATUS) { + const batch = record; + if (!batch.resultsFetched) { + return false; // terminal status, but results still need fetching — keep refreshable + } + } + // AB test: if terminal due to failure but failureReason not yet captured, keep refreshable. + if (record.type === 'ab-test' && record.status !== NOT_FOUND_STATUS && !record.failureReason) { + return false; + } + return true; +} diff --git a/src/cli/operations/jobs/shared/engine.ts b/src/cli/operations/jobs/shared/engine.ts new file mode 100644 index 000000000..fa4d791a8 --- /dev/null +++ b/src/cli/operations/jobs/shared/engine.ts @@ -0,0 +1,231 @@ +/** + * Job Engine factory. NOT a singleton — `createJobEngine(configIO)` per call site; configIO is + * injected and read ONLY inside handler.create(). The engine owns persistence (start → save) and + * the refresh-on-read lifecycle; commands/TUI stay thin. + */ +import { ConfigIO, JobNotFoundError } from '../../../../lib'; +import type { Result } from '../../../../lib/result'; +import { validateAwsCredentials } from '../../../aws/account'; +import { abTestHandler } from '../ab-test/handler'; +import { batchEvaluationHandler } from '../batch-evaluation/handler'; +import { insightsHandler } from '../insights/handler'; +import { recommendationHandler } from '../recommendation/handler'; +import { JOB_CAPABILITIES, JOB_TYPE_LABELS, isTerminal } from './constants'; +import { deleteRecord, listRecords, loadRecord, saveRecord } from './storage'; +import type { + DebugCheckResult, + DebuggableJobType, + HandlerByType, + JobCapabilities, + JobEngine, + JobRecord, + JobType, + ListOptions, + PausableJobType, + PromotableJobType, + RecordByType, + StartOptionsByType, + StoppableJobType, +} from './types'; + +/** Static registry; `satisfies` makes a missing trait on any handler a compile-time error. */ +const handlers = { + recommendation: recommendationHandler, + 'batch-evaluation': batchEvaluationHandler, + 'ab-test': abTestHandler, + insights: insightsHandler, +} as const satisfies HandlerByType; + +/** Does this handler compose the optional Settles trait? */ +function hasSettle( + handler: HandlerByType[T] +): handler is HandlerByType[T] & { settle: (r: JobRecord, c: ConfigIO) => Promise } { + return typeof (handler as { settle?: unknown }).settle === 'function'; +} + +export function createJobEngine(configIO: ConfigIO = new ConfigIO()): JobEngine { + const REFRESH_MAX_RETRIES = 3; + + /** + * Refresh one record from the service and persist it. Retries up to REFRESH_MAX_RETRIES on failure. + * On exhausted retries, persists the error on the record (makes it terminal via isTerminal()). + * Does NOT run settle() (that's sequential). + */ + async function refreshOne(record: Extract): Promise { + if (isTerminal(record)) { + return record; + } + const handler = handlers[record.type]; + type RefreshFn = (r: typeof record) => Promise>; + let lastErr: Error | undefined; + + for (let attempt = 0; attempt < REFRESH_MAX_RETRIES; attempt++) { + const result = await (handler.refresh as RefreshFn)(record); + if (result.success) { + const updated = result.record; + if (updated.error) { + delete (updated as { error?: string }).error; + } + saveRecord(updated); + return updated; + } + lastErr = result.error; + } + + // All retries exhausted — persist the error so isTerminal() settles and the user sees it. + const failed: JobRecord = { ...record, error: lastErr!.message }; + saveRecord(failed); + return failed; + } + + /** Run a handler's optional settle() step (sequential; may mutate project config). */ + async function settleOne(record: JobRecord): Promise { + const handler = handlers[record.type]; + if (!hasSettle(handler)) { + return record; + } + try { + const settled = await handler.settle(record, configIO); + if (settled !== record) { + saveRecord(settled); + } + return settled; + } catch { + return record; + } + } + + return { + async start(type: T, opts: StartOptionsByType[T]): Promise> { + const creds = await validateCredentials(); + if (!creds.success) { + return creds; + } + const handler = handlers[type]; + // create is typed per-handler; the registry guarantees opts/record line up with `type`. + const result = await ( + handler.create as (o: StartOptionsByType[T], c: ConfigIO) => Promise> + )(opts, configIO); + if (result.success) { + saveRecord(result.record); + } + return result; + }, + + async get(type: T, id: string): Promise { + const record = loadRecord(type, id); + if (!record) { + return undefined; + } + const refreshed = await refreshOne(record); + const settled = await settleOne(refreshed); + return settled as RecordByType[T]; + }, + + async list(opts?: ListOptions): Promise { + const records = listRecords(opts?.type); + // Refresh statuses in parallel... + const refreshed = await Promise.all(records.map(r => refreshOne(r))); + // ...then run any config-mutating settle steps SEQUENTIALLY (avoids concurrent agentcore.json writes). + const settled: JobRecord[] = []; + for (const r of refreshed) { + settled.push(await settleOne(r)); + } + let out = settled; + if (opts?.agent) { + out = out.filter(r => r.agent === opts.agent); + } + out.sort((a, b) => (b.createdAt > a.createdAt ? 1 : b.createdAt < a.createdAt ? -1 : a.id < b.id ? -1 : 1)); + if (opts?.limit != null) { + out = out.slice(0, opts.limit); + } + return out; + }, + + async stop(type: StoppableJobType, id: string): Promise { + const record = loadRecord(type, id); + if (!record) { + return { success: false, error: new JobNotFoundError(`${JOB_TYPE_LABELS[type]} "${id}" not found.`) }; + } + // Only stoppable handlers are reachable here (type-narrowed); the registry guarantees the match. + const result = await (handlers[type].stop as (r: JobRecord) => Promise)(record); + if (result.success) { + saveRecord({ ...record, status: 'STOPPING' }); + } + return result; + }, + + async pause(type: PausableJobType, id: string): Promise { + const record = loadRecord(type, id); + if (!record) { + return { success: false, error: new JobNotFoundError(`${JOB_TYPE_LABELS[type]} "${id}" not found.`) }; + } + const result = await (handlers[type].pause as (r: JobRecord) => Promise>)(record); + if (result.success) { + saveRecord(result.record); + } + return result; + }, + + async resume(type: PausableJobType, id: string): Promise { + const record = loadRecord(type, id); + if (!record) { + return { success: false, error: new JobNotFoundError(`${JOB_TYPE_LABELS[type]} "${id}" not found.`) }; + } + const result = await (handlers[type].resume as (r: JobRecord) => Promise>)(record); + if (result.success) { + saveRecord(result.record); + } + return result; + }, + + async promote(type: PromotableJobType, id: string): Promise { + const record = loadRecord(type, id); + if (!record) { + return { success: false, error: new JobNotFoundError(`${JOB_TYPE_LABELS[type]} "${id}" not found.`) }; + } + const result = await ( + handlers[type].promote as (r: JobRecord, c: ConfigIO) => Promise> + )(record, configIO); + if (result.success) { + saveRecord(result.record); + } + return result; + }, + + async archive(type: JobType, id: string): Promise { + const record = loadRecord(type, id); + if (!record) { + return { success: false, error: new JobNotFoundError(`${JOB_TYPE_LABELS[type]} "${id}" not found.`) }; + } + const handler = handlers[type]; + const result = await (handler.archive as (r: JobRecord) => Promise)(record); + if (result.success) { + deleteRecord(type, id); + } + return result; + }, + + async debug(type: DebuggableJobType, id: string): Promise> { + const record = loadRecord(type, id); + if (!record) { + return { success: false, error: new JobNotFoundError(`${JOB_TYPE_LABELS[type]} "${id}" not found.`) }; + } + return (handlers[type].debug as (r: JobRecord) => Promise>)(record); + }, + + capabilities(type: JobType): JobCapabilities { + return JOB_CAPABILITIES[type]; + }, + }; +} + +/** Wrap validateAwsCredentials (which throws) into a Result so start() can return it cleanly. */ +async function validateCredentials(): Promise { + try { + await validateAwsCredentials(); + return { success: true }; + } catch (err) { + return { success: false, error: err instanceof Error ? err : new Error(String(err)) }; + } +} diff --git a/src/cli/operations/jobs/shared/format.ts b/src/cli/operations/jobs/shared/format.ts new file mode 100644 index 000000000..563de5985 --- /dev/null +++ b/src/cli/operations/jobs/shared/format.ts @@ -0,0 +1,13 @@ +/** Shared presentation helpers for job CLI output. */ + +/** Format an ISO timestamp for CLI tables/detail output (shared by all job types). */ +export function formatJobDate(iso: string | undefined): string { + if (!iso) return 'unknown'; + return new Date(iso).toLocaleString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/src/cli/operations/jobs/shared/region.ts b/src/cli/operations/jobs/shared/region.ts new file mode 100644 index 000000000..739b9d64f --- /dev/null +++ b/src/cli/operations/jobs/shared/region.ts @@ -0,0 +1,36 @@ +/** + * Region resolution for jobs. Region is resolved ONCE in create() (superset precedence, + * no regression to either legacy path) and baked into the stored ARN; refresh/stop/archive + * parse it back out of the ARN rather than storing a separate field. + */ +import { detectRegion } from '../../../aws/region'; + +/** AWS targets carry a per-target region; we only need that field here. */ +interface RegionTarget { + region: string; +} + +/** + * Resolve the region for a new job, once, at create() time. + * Precedence (superset of both legacy paths): explicit option → first deployment target → detected region. + */ +export async function resolveJobRegion(optsRegion: string | undefined, awsTargets: RegionTarget[]): Promise { + if (optsRegion) { + return optsRegion; + } + if (awsTargets.length > 0 && awsTargets[0]!.region) { + return awsTargets[0]!.region; + } + const { region } = await detectRegion(); + return region; +} + +/** + * Parse the region out of a service ARN. + * ARN format: arn:{partition}:{service}:{region}:{account}:{resource} → field index 3 is the region. + * Engine-created ARNs are always well-formed; returns undefined for a malformed/region-less ARN. + */ +export function regionFromArn(arn: string): string | undefined { + const region = arn.split(':')[3]; + return region && region.length > 0 ? region : undefined; +} diff --git a/src/cli/operations/jobs/shared/resolve-agent-state.ts b/src/cli/operations/jobs/shared/resolve-agent-state.ts new file mode 100644 index 000000000..8335664b6 --- /dev/null +++ b/src/cli/operations/jobs/shared/resolve-agent-state.ts @@ -0,0 +1,21 @@ +/** + * Resolve a deployed agent runtime from deployed state by name. + * Hoisted here to dedupe the copies previously inlined in run-recommendation.ts and + * run-batch-evaluation.ts. + */ +import type { DeployedState } from '../../../../schema'; + +export interface ResolvedAgentState { + runtimeId: string; + runtimeArn: string; + roleArn?: string; +} + +/** Find the agent runtime across all deployment targets; undefined if not deployed. */ +export function resolveAgentState(deployedState: DeployedState, agentName: string): ResolvedAgentState | undefined { + for (const target of Object.values(deployedState.targets)) { + const agent = target.resources?.runtimes?.[agentName]; + if (agent) return agent; + } + return undefined; +} diff --git a/src/cli/operations/jobs/shared/storage.ts b/src/cli/operations/jobs/shared/storage.ts new file mode 100644 index 000000000..845a43195 --- /dev/null +++ b/src/cli/operations/jobs/shared/storage.ts @@ -0,0 +1,114 @@ +/** + * Pure file I/O for job records: `/.cli/jobs/{STORAGE_DIRS[type]}/{id}.json`. + * + * Hardened over the legacy per-type storage modules: + * - atomic writes (temp file + rename) so a killed process can't leave half-written JSON, + * - per-file parse guard in listRecords (one corrupt file is skipped, never crashes the list), + * - assertSafeId path-traversal guard, and content-`id` is authoritative over the filename. + * + * Legacy (pre-engine) records in the old shape are intentionally ignored (fresh start): any file + * that doesn't parse to a current JobRecord (missing/mismatched `type`) is skipped by listRecords + * and treated as not-found by loadRecord. + */ +import { CLI_SYSTEM_DIR, NoProjectError, findConfigRoot } from '../../../../lib'; +import { STORAGE_DIRS } from './constants'; +import type { JobRecord, JobType } from './types'; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +function cliDir(): string { + const configRoot = findConfigRoot(); + if (!configRoot) { + throw new NoProjectError(); + } + return join(configRoot, CLI_SYSTEM_DIR); +} + +function dirFor(type: JobType): string { + return join(cliDir(), 'jobs', STORAGE_DIRS[type]); +} + +function fileFor(type: JobType, id: string): string { + return join(dirFor(type), `${id}.json`); +} + +/** Reject ids that could escape the storage directory. */ +export function assertSafeId(id: string): void { + if (!id || /[/\\]/.test(id)) { + throw new Error(`Invalid job id: must be non-empty and contain no path separators`); + } +} + +/** Is this parsed object a current-shape JobRecord for the expected type? */ +function isJobRecordOfType(value: unknown, type: JobType): value is JobRecord { + if (value === null || typeof value !== 'object') return false; + const rec = value as Partial; + return rec.type === type && typeof rec.id === 'string' && rec.id.length > 0; +} + +/** Persist a record atomically (temp file + rename). */ +export function saveRecord(record: JobRecord): string { + assertSafeId(record.id); + const dir = dirFor(record.type); + mkdirSync(dir, { recursive: true }); + + const filePath = fileFor(record.type, record.id); + const tmpPath = `${filePath}.tmp`; + writeFileSync(tmpPath, JSON.stringify(record, null, 2)); + renameSync(tmpPath, filePath); + return filePath; +} + +/** Load one record. Returns undefined if missing, unparseable, or a legacy/foreign shape. */ +export function loadRecord(type: T, id: string): Extract | undefined { + assertSafeId(id); + const filePath = fileFor(type, id); + if (!existsSync(filePath)) { + return undefined; + } + try { + const parsed = JSON.parse(readFileSync(filePath, 'utf-8')) as unknown; + if (!isJobRecordOfType(parsed, type)) { + return undefined; // legacy/foreign shape — ignored (fresh start) + } + return parsed as Extract; + } catch { + return undefined; // corrupt file — treat as not found + } +} + +/** List records for one type (or all types). Skips corrupt/legacy files; never throws on a bad file. */ +export function listRecords(type: T): Extract[]; +export function listRecords(type?: JobType): JobRecord[]; +export function listRecords(type?: JobType): JobRecord[] { + const types: JobType[] = type ? [type] : (Object.keys(STORAGE_DIRS) as JobType[]); + const records: JobRecord[] = []; + + for (const t of types) { + const dir = dirFor(t); + if (!existsSync(dir)) continue; + for (const file of readdirSync(dir)) { + if (!file.endsWith('.json')) continue; + try { + const parsed = JSON.parse(readFileSync(join(dir, file), 'utf-8')) as unknown; + if (isJobRecordOfType(parsed, t)) { + records.push(parsed); // trust content `id`, not the filename + } + } catch { + // skip corrupt/half-written file + } + } + } + return records; +} + +/** Delete the local record file. Returns true if it existed and was removed. */ +export function deleteRecord(type: JobType, id: string): boolean { + assertSafeId(id); + const filePath = fileFor(type, id); + if (!existsSync(filePath)) { + return false; + } + rmSync(filePath); + return true; +} diff --git a/src/cli/operations/jobs/shared/types.ts b/src/cli/operations/jobs/shared/types.ts new file mode 100644 index 000000000..7f501c522 --- /dev/null +++ b/src/cli/operations/jobs/shared/types.ts @@ -0,0 +1,456 @@ +/** + * Job Engine type system (Design 2 — composed traits + type-narrowed signatures). + * + * A "job" is an async, fire-and-forget operation (recommendation or batch evaluation): + * `start` makes one API call + saves a local record; `get`/`list` refresh non-terminal + * records on read. Handlers are composed from small traits; the engine's public surface + * is type-narrowed so illegal operations (e.g. stopping a recommendation) are compile errors. + */ +import type { ConfigIO } from '../../../../lib'; +import type { Result } from '../../../../lib/result'; +import type { ABTestEvaluationConfig, ABTestResults, GatewayFilter } from '../../../aws/agentcore-ab-tests'; +import type { + BatchEvaluationResultEntry, + EvaluationResults, + SessionMetadataEntry, +} from '../../../aws/agentcore-batch-evaluation'; +import type { RecommendationResult, RecommendationType } from '../../../aws/agentcore-recommendation'; + +export type { RecommendationType } from '../../../aws/agentcore-recommendation'; + +// ============================================================================ +// Job types & statuses +// ============================================================================ + +/** The job types this engine manages. */ +export type JobType = 'recommendation' | 'batch-evaluation' | 'ab-test' | 'insights'; + +/** AB test creation mode — bundle-versioned variants vs gateway-target variants. */ +export type ABTestMode = 'config-bundle' | 'target-based'; + +/** CLI-facing input source for a recommendation. */ +export type RecommendationInputSource = 'config-bundle' | 'inline' | 'file'; + +/** CLI-facing trace source for a recommendation. */ +export type RecommendationTraceSource = 'cloudwatch' | 'sessions' | 'spans-file' | 'batch-evaluation'; + +/** Where the batch evaluation sessions came from. */ +export type BatchEvaluationSource = 'traces' | 'dataset'; + +/** Tool name → JSONPath pairs for config-bundle tool descriptions. */ +export interface ToolDescJsonPath { + toolName: string; + toolDescriptionJsonPath: string; +} + +// ============================================================================ +// Records (discriminated union on `type`) +// ============================================================================ + +/** Fields every job record carries, regardless of type. Note: region is NOT stored — parse from `arn`. */ +export interface JobRecordBase { + /** Job type discriminator. */ + type: JobType; + /** Service-assigned job id (also the storage filename). */ + id: string; + /** Service ARN — region is parsed back out of this for refresh/stop/archive. */ + arn: string; + /** Latest known status (raw service string; may be a value not in the JobStatus union). */ + status: string; + /** ISO timestamp the job was created (API value, else local clock at create time). */ + createdAt: string; + /** ISO timestamp the job reached a terminal state. */ + completedAt?: string; + /** Agent the job ran against. */ + agent: string; + /** Path to the local ExecLogger trace for the start call. */ + logFilePath?: string; + /** Persistent error from the last failed refresh (after retries exhausted). Settles the record. */ + error?: string; +} + +export interface RecommendationJobRecord extends JobRecordBase { + type: 'recommendation'; + recommendationType: RecommendationType; + /** Raw user-supplied evaluator display name(s) (resolved to ARNs only transiently for the API). */ + evaluators: string[]; + inputSource: RecommendationInputSource; + /** Source config-bundle identity (needed by the apply-to-bundle settle step). */ + bundleName?: string; + bundleArn?: string; + bundleVersion?: string; + systemPromptJsonPath?: string; + toolDescJsonPaths?: ToolDescJsonPath[]; + /** Optimized artifact, populated by refresh() once COMPLETED. */ + result?: RecommendationResult; + /** Top-level failure reasons from the API (FAILED only). */ + statusReasons?: string[]; + /** Flattened failure detail (errorCode/errorMessage) for display (FAILED only). */ + failureDetail?: string; + /** New config-bundle version already synced to agentcore.json (idempotency guard for settle). */ + syncedVersionId?: string; + /** Customer-managed KMS key ARN used to encrypt results (echoes the --kms-key value sent at create). */ + kmsKeyArn?: string; +} + +export interface BatchEvaluationJobRecord extends JobRecordBase { + type: 'batch-evaluation'; + name: string; + /** Resolved evaluator ids sent to the API (short ids / Builtin.*). */ + evaluators: string[]; + source?: BatchEvaluationSource; + dataset?: { id: string; version: string }; + /** Server-computed evaluator summaries (from GetBatchEvaluation). */ + evaluationResults?: EvaluationResults; + /** Per-session scores fetched from CloudWatch output logs on terminal status. */ + results?: BatchEvaluationResultEntry[]; + /** True once per-session results have been fetched (gates the on-terminal retry). */ + resultsFetched?: boolean; + /** Customer-managed KMS key ARN used to encrypt results (echoes --kms-key; refreshed from GetBatchEvaluation). */ + kmsKeyArn?: string; +} + +/** Variant summary stored on the AB-test record (the resolved ARNs/targets sent to the API). */ +export interface ABTestVariantSummary { + name: 'C' | 'T1'; + weight: number; + bundleArn?: string; + bundleVersion?: string; + targetName?: string; +} + +export interface ABTestJobRecord extends JobRecordBase { + type: 'ab-test'; + /** + * AB test has two API axes: `status` (ACTIVE/FAILED/CREATE_FAILED) and `executionStatus` + * (RUNNING/PAUSED/STOPPED). We store lifecycle `status` in JobRecordBase.status (so + * isTerminal() works like other job types), and keep `executionStatus` here for + * keybinding gating (P/R/S). + */ + lifecycleStatus: string; + name: string; + mode: ABTestMode; + gatewayArn: string; + /** Gateway NAME (spec key) — needed by promote() to locate gateway targets in agentcore.json. */ + gatewayName?: string; + roleArn?: string; + /** True when the CLI auto-created the role in create() (so archive() cleans it up). */ + roleCreatedByCli?: boolean; + variants: ABTestVariantSummary[]; + evaluationConfig: ABTestEvaluationConfig; + /** Gateway filter applied to the test (single target path), persisted for display in `view ab-test`. */ + gatewayFilter?: GatewayFilter; + maxDurationExpiresAt?: string; + /** Per-evaluator comparison metrics, populated by refresh(). */ + results?: ABTestResults; + failureReason?: string; +} + +// ============================================================================ +// Insights (failure analysis) types +// ============================================================================ + +export interface InsightRelatedSession { + sessionId?: string; + recommendationType?: string; +} + +export interface InsightRootCause { + rootCauseCategory?: string; + rootCauseDescription?: string; + recommendation?: string; + relatedSessions?: InsightRelatedSession[]; +} + +export interface InsightFailureCategory { + failureCategoryName?: string; + failureCategoryDescription?: string; + categoryGroupName?: string; + rootCauses?: InsightRootCause[]; +} + +export interface FailureAnalysisResult { + failureCategories?: InsightFailureCategory[]; +} + +export interface InsightsJobRecord extends JobRecordBase { + type: 'insights'; + name: string; + /** Insight types requested. */ + insights: string[]; + /** Optional evaluators (needed for recommendation chaining). */ + evaluators?: string[]; + /** Server-computed evaluation results. */ + evaluationResults?: EvaluationResults; + /** Structured failure analysis results from GetBatchEvaluation. */ + failureAnalysisResult?: FailureAnalysisResult; +} + +export type JobRecord = RecommendationJobRecord | BatchEvaluationJobRecord | ABTestJobRecord | InsightsJobRecord; + +// ============================================================================ +// Start options (engine-facing; non-colliding with the AWS-layer Start* types) +// ============================================================================ + +export interface StartRecommendationJobOptions { + type: RecommendationType; + agent?: string; + /** Evaluator name(s), Builtin.* ids, or ARNs (exactly one for system-prompt; none for tool-description). */ + evaluators: string[]; + inputSource: RecommendationInputSource; + bundleName?: string; + bundleVersion?: string; + systemPromptJsonPath?: string; + toolDescJsonPaths?: ToolDescJsonPath[]; + inlineContent?: string; + promptFile?: string; + tools?: string[]; + traceSource: RecommendationTraceSource; + lookbackDays?: number; + sessionIds?: string[]; + spansFile?: string; + /** Use a local insights run as trace source (resolves batchEvaluationArn from .cli/jobs/insights/) */ + fromInsights?: string; + /** Use a batch evaluation ARN directly as trace source */ + batchEvaluationArn?: string; + region?: string; + /** Optional recommendation name. */ + recommendationName?: string; + /** KMS key ARN for encrypting recommendation results. */ + kmsKeyArn?: string; + /** Progress for the slow pre-start span fetch (sessions/spans-file). */ + onProgress?: (status: string, message: string) => void; +} + +export interface StartBatchEvaluationJobOptions { + agent: string; + /** Evaluator name(s) / Builtin.* ids (resolved to short ids in create()). */ + evaluators: string[]; + name?: string; + region?: string; + /** Sessions to evaluate (caller resolves these from a dataset Phase-1 run when applicable). */ + sessionIds?: string[]; + /** Lookback window (used only when no sessionIds are given). */ + lookbackDays?: number; + /** Ground-truth metadata (explicit or dataset-derived; caller supplies). */ + sessionMetadata?: SessionMetadataEntry[]; + /** Runtime endpoint name (e.g. PROMPT_V1). */ + endpoint?: string; + /** Recorded on the job for display; the engine does NOT run dataset Phase-1 (caller does). */ + source?: BatchEvaluationSource; + dataset?: { id: string; version: string }; + /** KMS key ARN for encrypting batch evaluation results. */ + kmsKeyArn?: string; + onProgress?: (status: string, message: string) => void; +} + +export interface StartABTestJobOptions { + name: string; + mode: ABTestMode; + description?: string; + /** Gateway NAME — resolved to an ARN in create() against deployed state (must pre-exist). */ + gateway: string; + /** Config-bundle mode: the runtime being tested (also used as the record's `agent`). */ + agent?: string; + // ── config-bundle mode ── + controlBundle?: string; + controlVersion?: string; + treatmentBundle?: string; + treatmentVersion?: string; + /** Single online-eval config name/ARN (applies to both variants). */ + onlineEval?: string; + // ── target-based mode ── + runtime?: string; + controlTarget?: string; + treatmentTarget?: string; + controlOnlineEval?: string; + treatmentOnlineEval?: string; + gatewayFilter?: string; + // ── shared ── + controlWeight: number; + treatmentWeight: number; + enableOnCreate?: boolean; + region?: string; + /** Optional role ARN override; auto-created in create() when omitted. */ + roleArn?: string; + onProgress?: (status: string, message: string) => void; +} + +export interface StartInsightsJobOptions { + agent?: string; + insights: string[]; + evaluators?: string[]; + onlineEvalConfigArn?: string; + lookbackDays?: number; + startTime?: string; + endTime?: string; + sessionIds?: string[]; + name?: string; + region?: string; + endpoint?: string; + onProgress?: (status: string, message: string) => void; +} + +export interface ListOptions { + type?: JobType; + limit?: number; + agent?: string; +} + +// ============================================================================ +// Traits — small focused capabilities composed per job type +// ============================================================================ + +/** Create the job on the service and return the initial record. configIO is injected ONLY here. */ +export interface Startable { + create(opts: O, configIO: ConfigIO): Promise>; +} + +/** Fetch latest state from the service. Returns Result so the engine handles retries/error-persist. */ +export interface Refreshable { + refresh(record: J): Promise>; +} + +/** Stop a running job. */ +export interface Stoppable { + stop(record: J): Promise; +} + +/** Delete the job from the service. */ +export interface Archivable { + archive(record: J): Promise; +} + +/** + * Optional per-type "settle" step the engine runs SEQUENTIALLY after a record first + * reaches a terminal status — separate from the parallel refresh() because it may mutate + * project config (and therefore needs configIO and must not race). Only recommendation + * composes this (apply-to-bundle sync). + */ +export interface Settles { + settle(record: J, configIO: ConfigIO): Promise; +} + +/** Pause/resume a running job (AB test only). */ +export interface Pausable { + pause(record: J): Promise>; + resume(record: J): Promise>; +} + +/** Promote a job's result into project config (AB test only — stop + apply winning variant). */ +export interface Promotable { + promote(record: J, configIO: ConfigIO): Promise>; +} + +/** Structured result from a live health/debug check. */ +export interface DebugCheckResult { + label: string; + status: 'pass' | 'fail' | 'warn'; + detail: string; +} + +/** Run live diagnostic checks against the service (AB test: eval config, gateway, spans). */ +export interface Debuggable { + debug(record: J): Promise>; +} + +// ============================================================================ +// Composed handlers + registries +// ============================================================================ + +export type RecommendationHandler = Startable & + Refreshable & + Settles & + Archivable; + +export type BatchEvaluationHandler = Startable & + Refreshable & + Stoppable & + Archivable; + +export type ABTestHandler = Startable & + Refreshable & + Stoppable & + Pausable & + Promotable & + Debuggable & + Archivable; + +export type InsightsHandler = Startable & + Refreshable & + Archivable; + +export interface RecordByType { + recommendation: RecommendationJobRecord; + 'batch-evaluation': BatchEvaluationJobRecord; + 'ab-test': ABTestJobRecord; + insights: InsightsJobRecord; +} + +export interface StartOptionsByType { + recommendation: StartRecommendationJobOptions; + 'batch-evaluation': StartBatchEvaluationJobOptions; + 'ab-test': StartABTestJobOptions; + insights: StartInsightsJobOptions; +} + +export interface HandlerByType { + recommendation: RecommendationHandler; + 'batch-evaluation': BatchEvaluationHandler; + 'ab-test': ABTestHandler; + insights: InsightsHandler; +} + +/** Job types whose handler composes a given trait — derived so they track trait composition automatically. */ +export type StoppableJobType = { + [K in JobType]: HandlerByType[K] extends Stoppable ? K : never; +}[JobType]; + +export type PausableJobType = { + [K in JobType]: HandlerByType[K] extends Pausable ? K : never; +}[JobType]; + +export type PromotableJobType = { + [K in JobType]: HandlerByType[K] extends Promotable ? K : never; +}[JobType]; + +export type DebuggableJobType = { + [K in JobType]: HandlerByType[K] extends Debuggable ? K : never; +}[JobType]; + +// ============================================================================ +// Engine +// ============================================================================ + +/** Runtime capability flags for TUI affordances (display only — legality is enforced by types). */ +export interface JobCapabilities { + canStop: boolean; + canPause: boolean; + canPromote: boolean; + canDebug: boolean; +} + +export interface JobEngine { + /** Resolve + ONE API call + save. configIO is read only here. */ + start(type: T, opts: StartOptionsByType[T]): Promise>; + /** Read one record; refresh (+ settle) if non-terminal. */ + get(type: T, id: string): Promise; + /** List records of one type; refresh non-terminal in parallel, settle sequentially. */ + list(opts: ListOptions & { type: T }): Promise; + /** List across all types (union return). */ + list(opts?: ListOptions): Promise; + /** Stop a running job — only stoppable types accepted (compile-time narrowed). */ + stop(type: StoppableJobType, id: string): Promise; + /** Pause a running job — only pausable types accepted (compile-time narrowed). */ + pause(type: PausableJobType, id: string): Promise; + /** Resume a paused job — only pausable types accepted (compile-time narrowed). */ + resume(type: PausableJobType, id: string): Promise; + /** Promote a job's result into project config — only promotable types accepted (compile-time narrowed). */ + promote(type: PromotableJobType, id: string): Promise; + /** Delete from the service + remove the local file. */ + archive(type: JobType, id: string): Promise; + /** Run live diagnostic checks — only debuggable types accepted (compile-time narrowed). */ + debug(type: DebuggableJobType, id: string): Promise>; + /** Display-only capability flags for the TUI. */ + capabilities(type: JobType): JobCapabilities; +} diff --git a/src/cli/operations/jobs/shared/wait.ts b/src/cli/operations/jobs/shared/wait.ts new file mode 100644 index 000000000..c74d2d5a4 --- /dev/null +++ b/src/cli/operations/jobs/shared/wait.ts @@ -0,0 +1,38 @@ +/** + * Block until a job reaches a terminal state by polling engine.get(). + * + * Used by the CLI `--wait` flag. Lives in jobs/ (not the command layer) because it's pure engine + * lifecycle logic — poll get() until isTerminal — reusable by any caller that wants synchronous + * completion (CLI today, potentially a TUI "wait" affordance later). + */ +import { isTerminal } from './constants'; +import type { JobEngine, JobType, RecordByType } from './types'; + +const DEFAULT_POLL_INTERVAL_MS = 5000; + +export interface WaitForTerminalOptions { + /** Poll interval in ms (default 5000). */ + pollIntervalMs?: number; + /** Called with the latest status string on each poll (e.g. to print progress). */ + onTick?: (status: string) => void; +} + +/** + * Poll `engine.get(type, id)` until the job is terminal (or vanishes from storage). + * Returns the final record, or undefined if the job is no longer found locally. + */ +export async function waitForTerminal( + engine: JobEngine, + type: T, + id: string, + options: WaitForTerminalOptions = {} +): Promise { + const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + for (;;) { + const record = await engine.get(type, id); + if (!record) return undefined; + options.onTick?.(record.status); + if (isTerminal(record)) return record; + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + } +} diff --git a/src/cli/operations/knowledge-base/__tests__/agentic-retrieve-upsert.test.ts b/src/cli/operations/knowledge-base/__tests__/agentic-retrieve-upsert.test.ts new file mode 100644 index 000000000..8d2ab8248 --- /dev/null +++ b/src/cli/operations/knowledge-base/__tests__/agentic-retrieve-upsert.test.ts @@ -0,0 +1,59 @@ +import type { AgentCoreProjectSpec } from '../../../../schema'; +import { upsertAgenticRetrieveTarget } from '../agentic-retrieve-upsert'; +import { describe, expect, it } from 'vitest'; + +type Gateway = AgentCoreProjectSpec['agentCoreGateways'][number]; + +function makeGateway(name = 'main-gw'): Gateway { + return { + name, + targets: [], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + } as unknown as Gateway; +} + +describe('upsertAgenticRetrieveTarget', () => { + it('creates a new agentic-retrieve target on first call', () => { + const gw = makeGateway(); + upsertAgenticRetrieveTarget(gw, 'kb-1'); + expect(gw.targets).toHaveLength(1); + const target = gw.targets[0]; + expect(target?.name).toBe('main-gw-agentic'); + expect(target?.targetType).toBe('connector'); + expect(target?.connectorId).toBe('bedrock-agentic-retrieve'); + expect(target?.knowledgeBaseIds).toEqual(['kb-1']); + }); + + it('appends to an existing agentic-retrieve target', () => { + const gw = makeGateway(); + upsertAgenticRetrieveTarget(gw, 'kb-1'); + upsertAgenticRetrieveTarget(gw, 'kb-2'); + expect(gw.targets).toHaveLength(1); + expect(gw.targets[0]?.knowledgeBaseIds).toEqual(['kb-1', 'kb-2']); + }); + + it('is idempotent — re-adding the same kb is a no-op', () => { + const gw = makeGateway(); + upsertAgenticRetrieveTarget(gw, 'kb-1'); + upsertAgenticRetrieveTarget(gw, 'kb-1'); + expect(gw.targets).toHaveLength(1); + expect(gw.targets[0]?.knowledgeBaseIds).toEqual(['kb-1']); + }); + + it('respects a hand-renamed agentic target and only appends', () => { + const gw = makeGateway(); + gw.targets.push({ + name: 'custom-name', + targetType: 'connector', + connectorId: 'bedrock-agentic-retrieve', + knowledgeBaseIds: ['existing-kb'], + } as unknown as Gateway['targets'][number]); + + upsertAgenticRetrieveTarget(gw, 'new-kb'); + expect(gw.targets).toHaveLength(1); + expect(gw.targets[0]?.name).toBe('custom-name'); + expect(gw.targets[0]?.knowledgeBaseIds).toEqual(['existing-kb', 'new-kb']); + }); +}); diff --git a/src/cli/operations/knowledge-base/__tests__/connector-config.test.ts b/src/cli/operations/knowledge-base/__tests__/connector-config.test.ts new file mode 100644 index 000000000..6c4a67550 --- /dev/null +++ b/src/cli/operations/knowledge-base/__tests__/connector-config.test.ts @@ -0,0 +1,119 @@ +import { + CONNECTOR_TYPE_BY_FLAG, + FLAG_BY_CONNECTOR_TYPE, + extractSecretArn, + flagToWireType, + isConnectorConfigType, + readConnectorConfig, +} from '../connector-config'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +describe('connector-config flag↔wire mapping', () => { + it('maps every flag to its wire type', () => { + expect(flagToWireType('s3')).toBe('S3'); + expect(flagToWireType('web-crawler')).toBe('WEB'); + expect(flagToWireType('confluence')).toBe('CONFLUENCE'); + expect(flagToWireType('sharepoint')).toBe('SHAREPOINT'); + expect(flagToWireType('onedrive')).toBe('ONEDRIVE'); + expect(flagToWireType('google-drive')).toBe('GOOGLEDRIVE'); + }); + + it('throws on an unknown flag', () => { + expect(() => flagToWireType('dropbox')).toThrow(/unknown data source type/i); + }); + + it('round-trips flag → wire → flag', () => { + for (const flag of Object.keys(CONNECTOR_TYPE_BY_FLAG)) { + const wire = flagToWireType(flag); + expect(FLAG_BY_CONNECTOR_TYPE[wire]).toBe(flag); + } + }); + + it('identifies non-S3 connector wire types', () => { + expect(isConnectorConfigType('WEB')).toBe(true); + expect(isConnectorConfigType('S3')).toBe(false); + }); +}); + +describe('readConnectorConfig', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cc-')); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('reads and validates a WEB config whose type matches', () => { + const p = join(dir, 'web.json'); + writeFileSync( + p, + JSON.stringify({ + type: 'WEB', + version: '1', + connectionConfiguration: { authType: 'NO_AUTH', seedUrls: ['https://x/'] }, + crawlConfiguration: {}, + }) + ); + const r = readConnectorConfig(p, 'WEB'); + expect(r.parsed.type).toBe('WEB'); + expect(r.warnings).toHaveLength(0); + }); + + it('errors when the file is missing', () => { + expect(() => readConnectorConfig(join(dir, 'nope.json'), 'WEB')).toThrow(/not found/i); + }); + + it('errors on invalid JSON', () => { + const p = join(dir, 'bad.json'); + writeFileSync(p, '{ not json'); + expect(() => readConnectorConfig(p, 'WEB')).toThrow(/not valid JSON/i); + }); + + it('errors when the parsed value is not an object', () => { + const p = join(dir, 'arr.json'); + writeFileSync(p, '[]'); + expect(() => readConnectorConfig(p, 'WEB')).toThrow(/must be a JSON object/i); + }); + + it('errors when the config type disagrees with the declared type', () => { + const p = join(dir, 'mismatch.json'); + writeFileSync(p, JSON.stringify({ type: 'CONFLUENCE', connectionConfiguration: {} })); + expect(() => readConnectorConfig(p, 'WEB')).toThrow(/does not match/i); + }); + + it('warns (does not throw) when an auth connector has no secretArn', () => { + const p = join(dir, 'conf.json'); + writeFileSync( + p, + JSON.stringify({ type: 'CONFLUENCE', connectionConfiguration: { hostUrl: 'https://x', authType: 'OAUTH2' } }) + ); + const r = readConnectorConfig(p, 'CONFLUENCE'); + expect(r.warnings.some(w => /secretArn/i.test(w))).toBe(true); + }); + + it('does not warn for a WEB config with NO_AUTH and no secretArn', () => { + const p = join(dir, 'web2.json'); + writeFileSync(p, JSON.stringify({ type: 'WEB', connectionConfiguration: { authType: 'NO_AUTH' } })); + const r = readConnectorConfig(p, 'WEB'); + expect(r.warnings).toHaveLength(0); + }); +}); + +describe('extractSecretArn', () => { + it('returns the secretArn from connectionConfiguration', () => { + expect( + extractSecretArn({ + type: 'CONFLUENCE', + connectionConfiguration: { secretArn: 'arn:aws:secretsmanager:us-west-2:1:secret:x' }, + }) + ).toBe('arn:aws:secretsmanager:us-west-2:1:secret:x'); + }); + it('returns undefined when absent', () => { + expect(extractSecretArn({ type: 'WEB', connectionConfiguration: { authType: 'NO_AUTH' } })).toBeUndefined(); + }); + it('returns undefined when connectionConfiguration is missing', () => { + expect(extractSecretArn({ type: 'WEB' })).toBeUndefined(); + }); +}); diff --git a/src/cli/operations/knowledge-base/__tests__/hydrate-data-sources.test.ts b/src/cli/operations/knowledge-base/__tests__/hydrate-data-sources.test.ts new file mode 100644 index 000000000..2748d64cd --- /dev/null +++ b/src/cli/operations/knowledge-base/__tests__/hydrate-data-sources.test.ts @@ -0,0 +1,110 @@ +import * as bedrockAgent from '../../../aws/bedrock-agent'; +import { hydrateKnowledgeBaseDataSources } from '../hydrate-data-sources'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../aws/bedrock-agent'); + +describe('hydrateKnowledgeBaseDataSources', () => { + beforeEach(() => vi.mocked(bedrockAgent.listDataSources).mockReset()); + afterEach(() => vi.restoreAllMocks()); + + it('falls back to listDataSources, mapping deployed DSes by URI hash suffix', async () => { + // Stack pre-dates the per-DS CFN outputs from L3 #234, so dataSources is + // empty when we get here. The L3 names each DS as + // `${kbPhysicalName}_ds_${first8charsOfSha256(uri)}` + // Hashes computed from the URIs below: + // s3://b/a/ → 28ebaa59 + // s3://b/b/ → 87791e1d + // ListDataSources order is not guaranteed; we recover the URI by hash. + vi.mocked(bedrockAgent.listDataSources).mockResolvedValueOnce([ + { dataSourceId: 'DS-second', name: 'TestProj_docs_ds_87791e1d', status: 'AVAILABLE' }, + { dataSourceId: 'DS-first', name: 'TestProj_docs_ds_28ebaa59', status: 'AVAILABLE' }, + ] as never); + + const knowledgeBases = { + docs: { + knowledgeBaseId: 'KB1', + knowledgeBaseArn: 'arn:aws:bedrock:us-west-2:0:knowledge-base/KB1', + dataSources: [], + }, + }; + + await hydrateKnowledgeBaseDataSources({ + knowledgeBases, + knowledgeBaseSpecs: [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + dataSources: [ + { type: 'S3', uri: 's3://b/a/' }, + { type: 'S3', uri: 's3://b/b/' }, + ], + } as never, + ], + region: 'us-west-2', + }); + + expect(knowledgeBases.docs.dataSources).toEqual([ + { dataSourceId: 'DS-first', uri: 's3://b/a/' }, + { dataSourceId: 'DS-second', uri: 's3://b/b/' }, + ]); + }); + + it('is a no-op when CFN outputs already populated dataSources[]', async () => { + const listSpy = vi.mocked(bedrockAgent.listDataSources).mockResolvedValue([] as never); + const knowledgeBases = { + docs: { + knowledgeBaseId: 'KB1', + knowledgeBaseArn: 'arn:x', + dataSources: [{ dataSourceId: 'DS-from-cfn', uri: 's3://b/a/' }], + }, + }; + + await hydrateKnowledgeBaseDataSources({ + knowledgeBases, + knowledgeBaseSpecs: [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://b/a/' }], + } as never, + ], + region: 'us-west-2', + }); + + expect(listSpy).not.toHaveBeenCalled(); + expect(knowledgeBases.docs.dataSources).toEqual([{ dataSourceId: 'DS-from-cfn', uri: 's3://b/a/' }]); + }); + + it('leaves dataSources empty if listDataSources returns []', async () => { + vi.mocked(bedrockAgent.listDataSources).mockResolvedValueOnce([]); + const knowledgeBases = { + docs: { knowledgeBaseId: 'KB1', knowledgeBaseArn: 'arn:x', dataSources: [] as never[] }, + }; + await hydrateKnowledgeBaseDataSources({ + knowledgeBases, + knowledgeBaseSpecs: [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://b/d/' }], + } as never, + ], + region: 'us-west-2', + }); + expect(knowledgeBases.docs.dataSources).toEqual([]); + }); + + it('skips KBs without a matching local spec', async () => { + const listSpy = vi.mocked(bedrockAgent.listDataSources).mockResolvedValue([] as never); + const knowledgeBases = { + orphan: { knowledgeBaseId: 'KB1', knowledgeBaseArn: 'arn:x', dataSources: [] as never[] }, + }; + await hydrateKnowledgeBaseDataSources({ + knowledgeBases, + knowledgeBaseSpecs: [], // no specs + region: 'us-west-2', + }); + expect(listSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/operations/knowledge-base/__tests__/templates.test.ts b/src/cli/operations/knowledge-base/__tests__/templates.test.ts new file mode 100644 index 000000000..59cab6df9 --- /dev/null +++ b/src/cli/operations/knowledge-base/__tests__/templates.test.ts @@ -0,0 +1,18 @@ +import { readConnectorConfig } from '../connector-config'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +const ROOT = join(__dirname, '../../../../../docs/connector-config-templates'); + +describe('connector-config templates parse', () => { + it.each([ + ['web-crawler.json', 'WEB'], + ['confluence.json', 'CONFLUENCE'], + ['sharepoint.json', 'SHAREPOINT'], + ['onedrive.json', 'ONEDRIVE'], + ['google-drive.json', 'GOOGLEDRIVE'], + ] as const)('%s validates as %s', (file, type) => { + const r = readConnectorConfig(join(ROOT, file), type); + expect(r.parsed.type).toBe(type); + }); +}); diff --git a/src/cli/operations/knowledge-base/agentic-retrieve-upsert.ts b/src/cli/operations/knowledge-base/agentic-retrieve-upsert.ts new file mode 100644 index 000000000..10702b31d --- /dev/null +++ b/src/cli/operations/knowledge-base/agentic-retrieve-upsert.ts @@ -0,0 +1,42 @@ +import type { AgentCoreGatewayTarget, AgentCoreProjectSpec } from '../../../schema'; +import { CONNECTOR_ID } from '../../../schema'; + +/** + * Ensure exactly one bedrock-agentic-retrieve target exists on this gateway, + * with kbReference present in its knowledgeBaseIds[]. Idempotent. + * + * - Creates the target on first call (named `${gateway.name}-agentic`). + * - Appends to it on subsequent calls if kbReference is missing. + * - No-op if kbReference is already in the agentic target's knowledgeBaseIds[]. + * + * Mutates `gateway.targets` in place. Used by both KnowledgeBasePrimitive + * (project-owned KBs via `add knowledge-base --gateway`) and + * GatewayTargetPrimitive (external KBs via `add gateway-target --type + * connector --connector bedrock-knowledge-bases`) so wiring is consistent + * across paths. + * + * If the user has hand-renamed the agentic target, we respect it and only + * append; we don't rename it back. + */ +export function upsertAgenticRetrieveTarget( + gateway: AgentCoreProjectSpec['agentCoreGateways'][number], + kbReference: string +): void { + const existing = gateway.targets.find( + t => t.targetType === 'connector' && t.connectorId === CONNECTOR_ID.BEDROCK_AGENTIC_RETRIEVE + ); + if (existing) { + const ids = existing.knowledgeBaseIds ?? []; + if (!ids.includes(kbReference)) { + existing.knowledgeBaseIds = [...ids, kbReference]; + } + return; + } + const agenticTarget: AgentCoreGatewayTarget = { + name: `${gateway.name}-agentic`, + targetType: 'connector', + connectorId: CONNECTOR_ID.BEDROCK_AGENTIC_RETRIEVE, + knowledgeBaseIds: [kbReference], + } as AgentCoreGatewayTarget; + gateway.targets.push(agenticTarget); +} diff --git a/src/cli/operations/knowledge-base/connector-config.ts b/src/cli/operations/knowledge-base/connector-config.ts new file mode 100644 index 000000000..1849f1240 --- /dev/null +++ b/src/cli/operations/knowledge-base/connector-config.ts @@ -0,0 +1,116 @@ +import type { ConnectorDataSourceType } from '../../../schema'; +import { existsSync, readFileSync } from 'fs'; +import { resolve } from 'path'; + +/** + * User-facing `--data-source-type` flag values, including S3. `s3` is the + * default and maps to the inline-uri S3 data source; the rest map to + * connector-file data sources. + */ +export const DATA_SOURCE_TYPE_FLAGS = [ + 's3', + 'web-crawler', + 'confluence', + 'sharepoint', + 'onedrive', + 'google-drive', +] as const; +export type DataSourceTypeFlag = (typeof DATA_SOURCE_TYPE_FLAGS)[number]; + +/** All wire types (including S3). */ +export type DataSourceWireType = 'S3' | ConnectorDataSourceType; + +/** The single translation table: user flag → wire `type`. */ +export const CONNECTOR_TYPE_BY_FLAG: Record = { + s3: 'S3', + 'web-crawler': 'WEB', + confluence: 'CONFLUENCE', + sharepoint: 'SHAREPOINT', + onedrive: 'ONEDRIVE', + 'google-drive': 'GOOGLEDRIVE', +}; + +/** Inverse table, for rendering a wire type back as a user-facing flag. */ +export const FLAG_BY_CONNECTOR_TYPE: Record = Object.fromEntries( + Object.entries(CONNECTOR_TYPE_BY_FLAG).map(([flag, wire]) => [wire, flag]) +) as Record; + +export function flagToWireType(flag: string): DataSourceWireType { + const wire = CONNECTOR_TYPE_BY_FLAG[flag as DataSourceTypeFlag]; + if (!wire) { + throw new Error(`Unknown data source type "${flag}". Expected one of: ${DATA_SOURCE_TYPE_FLAGS.join(', ')}.`); + } + return wire; +} + +/** True for every wire type that uses a connectorConfigFile (i.e. not S3). */ +export function isConnectorConfigType(wire: string): wire is ConnectorDataSourceType { + return wire !== 'S3' && wire in FLAG_BY_CONNECTOR_TYPE; +} + +/** Connector wire types that require a secretArn unless explicitly NO_AUTH. */ +const SECRET_BEARING: ReadonlySet = new Set(['CONFLUENCE', 'SHAREPOINT', 'ONEDRIVE', 'GOOGLEDRIVE']); + +export interface ConnectorConfigReadResult { + /** The parsed connectorParameters object, passed through to the L3 verbatim. */ + parsed: Record & { type: string }; + /** Non-fatal advisories surfaced to the user (e.g. missing secretArn). */ + warnings: string[]; +} + +/** + * Read a `--connector-config` JSON file and validate it lightly. The CLI does + * NOT deeply validate connector-specific structure — that lives only in the + * file and is passed through to the DataSource verbatim (the DevEx "JSON file + * passthrough" decision). We only check: file exists, parses, carries a `type` + * field matching the declared connector type, and (for auth connectors) warn + * if no secretArn is present. + */ +export function readConnectorConfig(path: string, declaredType: ConnectorDataSourceType): ConnectorConfigReadResult { + const resolved = resolve(path); + if (!existsSync(resolved)) { + throw new Error(`Connector config file not found: ${path}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(resolved, 'utf-8')); + } catch { + throw new Error(`Connector config file is not valid JSON: ${path}`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Connector config file must be a JSON object: ${path}`); + } + const obj = parsed as Record; + if (typeof obj.type !== 'string') { + throw new Error(`Connector config file is missing a "type" field: ${path}`); + } + if (obj.type !== declaredType) { + throw new Error( + `Connector config "type" (${obj.type}) does not match the declared data source type (${declaredType}) in ${path}.` + ); + } + + const warnings: string[] = []; + const cc = obj.connectionConfiguration as Record | undefined; + const hasSecret = !!cc && typeof cc.secretArn === 'string' && !!cc.secretArn; + if (SECRET_BEARING.has(declaredType) && !hasSecret) { + warnings.push( + `Connector config ${path} has no connectionConfiguration.secretArn; ${declaredType} ingestion will fail at deploy until credentials are provided.` + ); + } + if (declaredType === 'WEB') { + const authType = typeof cc?.authType === 'string' ? cc.authType : undefined; + if (authType && authType !== 'NO_AUTH' && !hasSecret) { + warnings.push(`Connector config ${path} uses authType ${authType} but has no secretArn.`); + } + } + + return { parsed: obj as ConnectorConfigReadResult['parsed'], warnings }; +} + +/** Pull connectionConfiguration.secretArn from a parsed connector config, if present. */ +export function extractSecretArn(parsed: Record): string | undefined { + const cc = parsed.connectionConfiguration as Record | undefined; + const arn = cc?.secretArn; + return typeof arn === 'string' && arn ? arn : undefined; +} diff --git a/src/cli/operations/knowledge-base/hydrate-data-sources.ts b/src/cli/operations/knowledge-base/hydrate-data-sources.ts new file mode 100644 index 000000000..d7af5ff92 --- /dev/null +++ b/src/cli/operations/knowledge-base/hydrate-data-sources.ts @@ -0,0 +1,79 @@ +import type { KnowledgeBase, KnowledgeBaseDeployedState } from '../../../schema'; +import { listDataSources } from '../../aws/bedrock-agent'; +import { createHash } from 'node:crypto'; + +export interface HydrateInput { + /** KB deployed-state records as parsed from CFN outputs (id + arn populated, dataSources empty). */ + knowledgeBases: Record; + /** Local KB specs from agentcore.json — used to recover URIs for the deployed DS IDs. */ + knowledgeBaseSpecs: KnowledgeBase[]; + /** AWS region (passed through to bedrock-agent SDK calls). */ + region: string; +} + +/** + * The L3's `AgentCoreKnowledgeBase` names each DataSource as + * `${knowledgeBasePhysicalName}_ds_${uriHashPrefix}` + * where `uriHashPrefix` is the first 8 hex chars of SHA-256(uri). This must + * stay byte-equivalent to the L3's + * createHash('sha256').update(ds.uri).digest('hex').slice(0, 8) + * or the hash-based fallback below loses every DS. + */ +function uriHashPrefix(uri: string): string { + return createHash('sha256').update(uri).digest('hex').slice(0, 8); +} + +/** + * Hydrate the `dataSources[]` array on each KB deployed-state record. + * + * Preferred path: `parseKnowledgeBaseOutputs` already populates + * `dataSources[]` from per-DS CFN outputs (L3 #234 onward). This function is + * a no-op for KBs whose outputs were present. + * + * Fallback path: when CFN outputs are absent (stack was deployed against an + * older L3, or a partial deploy), call bedrock-agent:ListDataSources and + * pair each deployed DS with its local spec by URI-hash suffix. The L3 names + * each DS deterministically using the first 8 chars of SHA-256(uri); we + * compute the same hash for every local URI and look it up against the + * deployed DS names. This is robust to ListDataSources ordering changes and + * to data sources being added or removed between deploys. + * + * Leaves `dataSources` as an empty array if both paths fail — the caller + * decides how to surface partial hydration. + */ +export async function hydrateKnowledgeBaseDataSources(input: HydrateInput): Promise { + const specsByName = new Map(input.knowledgeBaseSpecs.map(s => [s.name, s])); + + for (const [name, deployed] of Object.entries(input.knowledgeBases)) { + if (deployed.dataSources.length > 0) continue; + + const spec = specsByName.get(name); + if (!spec) continue; + + const summaries = await listDataSources({ + region: input.region, + knowledgeBaseId: deployed.knowledgeBaseId, + }); + + // Build a hash-suffix → DS-id index from the deployed DSes so we can look + // up by URI hash without depending on ListDataSources ordering. + const idByHash = new Map(); + for (const summary of summaries) { + if (!summary.dataSourceId || !summary.name) continue; + const match = /_ds_([0-9a-f]+)$/.exec(summary.name); + if (!match) continue; + idByHash.set(match[1]!, summary.dataSourceId); + } + + // For each local DS spec, recover the deployed DS id by URI hash. + const hydrated: { dataSourceId: string; uri: string }[] = []; + for (const localDs of spec.dataSources) { + if (localDs.type !== 'S3') continue; + const dataSourceId = idByHash.get(uriHashPrefix(localDs.uri)); + if (!dataSourceId) continue; + hydrated.push({ dataSourceId, uri: localDs.uri }); + } + + deployed.dataSources = hydrated; + } +} diff --git a/src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts b/src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts index cade05ea5..d1b5c3fc3 100644 --- a/src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts +++ b/src/cli/operations/mcp/__tests__/create-mcp-utils.test.ts @@ -140,6 +140,7 @@ describe('GatewayPrimitive.add (createGateway)', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -174,6 +175,7 @@ describe('GatewayPrimitive.add (createGateway)', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -198,6 +200,7 @@ describe('GatewayPrimitive.add (createGateway)', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -225,6 +228,7 @@ describe('GatewayPrimitive.add (createGateway)', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], diff --git a/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts b/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts deleted file mode 100644 index 55c5b6eae..000000000 --- a/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { listAllRecommendations, loadRecommendationRun, saveRecommendationRun } from '../recommendation-storage'; -import type { RunRecommendationCommandResult } from '../types'; -import { existsSync, mkdirSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockFindConfigRoot = vi.fn(); - -vi.mock('../../../../lib', () => ({ - findConfigRoot: () => mockFindConfigRoot(), -})); - -function makeTmpDir(): string { - const dir = join(tmpdir(), `recommendation-storage-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function makeResult( - overrides: Partial> = {} -): RunRecommendationCommandResult { - return { - success: true, - recommendationId: 'rec-123', - status: 'COMPLETED', - startedAt: '2026-03-24T10:00:00.000Z', - completedAt: '2026-03-24T10:05:00.000Z', - result: { - systemPromptRecommendationResult: { - recommendedSystemPrompt: 'You are an expert booking assistant.', - }, - }, - ...overrides, - }; -} - -describe('recommendation-storage', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = makeTmpDir(); - mockFindConfigRoot.mockReturnValue(tmpDir); - }); - - afterEach(() => { - if (existsSync(tmpDir)) { - rmSync(tmpDir, { recursive: true, force: true }); - } - vi.clearAllMocks(); - }); - - describe('saveRecommendationRun', () => { - it('creates directory and writes JSON file', () => { - const result = makeResult(); - const filePath = saveRecommendationRun('rec-123', result, 'SYSTEM_PROMPT_RECOMMENDATION', 'booking-agent', [ - 'Builtin.Helpfulness', - ]); - - expect(filePath).toContain('recommendations'); - expect(filePath).toContain('rec-123.json'); - expect(existsSync(filePath)).toBe(true); - }); - - it('writes valid JSON that can be read back', () => { - const result = makeResult(); - saveRecommendationRun('rec-123', result, 'SYSTEM_PROMPT_RECOMMENDATION', 'booking-agent', [ - 'Builtin.Helpfulness', - ]); - - const loaded = loadRecommendationRun('rec-123'); - expect(loaded.recommendationId).toBe('rec-123'); - expect(loaded.type).toBe('SYSTEM_PROMPT_RECOMMENDATION'); - expect(loaded.agent).toBe('booking-agent'); - expect(loaded.evaluators).toEqual(['Builtin.Helpfulness']); - expect(loaded.result?.systemPromptRecommendationResult?.recommendedSystemPrompt).toBe( - 'You are an expert booking assistant.' - ); - }); - }); - - describe('loadRecommendationRun', () => { - it('loads a previously saved recommendation', () => { - saveRecommendationRun('rec-123', makeResult(), 'SYSTEM_PROMPT_RECOMMENDATION', 'agent', ['eval']); - const loaded = loadRecommendationRun('rec-123'); - expect(loaded.status).toBe('COMPLETED'); - }); - - it('accepts filename with .json extension', () => { - saveRecommendationRun('rec-123', makeResult(), 'SYSTEM_PROMPT_RECOMMENDATION', 'agent', ['eval']); - const loaded = loadRecommendationRun('rec-123.json'); - expect(loaded.recommendationId).toBe('rec-123'); - }); - - it('throws for a non-existent recommendation', () => { - expect(() => loadRecommendationRun('nonexistent')).toThrow('not found'); - }); - }); - - describe('listAllRecommendations', () => { - it('returns empty array when no recommendations exist', () => { - expect(listAllRecommendations()).toEqual([]); - }); - - it('returns saved recommendations in reverse order', () => { - saveRecommendationRun( - 'rec-aaa', - makeResult({ recommendationId: 'rec-aaa' }), - 'SYSTEM_PROMPT_RECOMMENDATION', - 'agent', - ['eval'] - ); - saveRecommendationRun( - 'rec-zzz', - makeResult({ recommendationId: 'rec-zzz' }), - 'TOOL_DESCRIPTION_RECOMMENDATION', - 'agent', - ['eval'] - ); - - const all = listAllRecommendations(); - expect(all).toHaveLength(2); - expect(all[0]!.recommendationId).toBe('rec-zzz'); - expect(all[1]!.recommendationId).toBe('rec-aaa'); - }); - }); - - describe('error when no config root', () => { - it('throws when findConfigRoot returns null', () => { - mockFindConfigRoot.mockReturnValue(null); - expect(() => - saveRecommendationRun('rec-123', makeResult(), 'SYSTEM_PROMPT_RECOMMENDATION', 'agent', ['eval']) - ).toThrow('No agentcore project found'); - }); - }); -}); diff --git a/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts deleted file mode 100644 index 765ee6692..000000000 --- a/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts +++ /dev/null @@ -1,720 +0,0 @@ -import { runRecommendationCommand } from '../run-recommendation'; -import assert from 'node:assert'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// Mock dependencies — paths are relative to the file under test (run-recommendation.ts) -const mockReadProjectSpec = vi.fn().mockResolvedValue({ name: 'test-project' }); -const mockReadDeployedState = vi.fn().mockResolvedValue({ - targets: { - default: { - resources: { - runtimes: { - MyAgent: { - runtimeId: 'rt-abc123', - runtimeArn: 'arn:aws:bedrock:us-east-1:998846730471:agent-runtime/rt-abc123', - }, - }, - evaluators: { - MyEvaluator: { - evaluatorArn: 'arn:aws:bedrock-agentcore:us-east-1:998846730471:evaluator/my-eval-abc1234567', - }, - }, - }, - }, - }, -}); - -vi.mock('../../../../lib', () => ({ - ConfigIO: class { - readProjectSpec = mockReadProjectSpec; - readDeployedState = mockReadDeployedState; - resolveAWSDeploymentTargets = vi.fn().mockResolvedValue([{ region: 'us-east-1' }]); - }, - toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), - ResourceNotFoundError: class extends Error { - constructor(m: string) { - super(m); - this.name = 'ResourceNotFoundError'; - } - }, - ValidationError: class extends Error { - constructor(m: string) { - super(m); - this.name = 'ValidationError'; - } - }, - TimeoutError: class extends Error { - constructor(m: string) { - super(m); - this.name = 'TimeoutError'; - } - }, -})); - -vi.mock('../../../aws/region', () => ({ - detectRegion: vi.fn().mockResolvedValue({ region: 'us-east-1' }), -})); - -const mockStartRecommendation = vi.fn(); -const mockGetRecommendation = vi.fn(); - -vi.mock('../../../aws/agentcore-recommendation', () => ({ - startRecommendation: (...args: unknown[]) => mockStartRecommendation(...args), - getRecommendation: (...args: unknown[]) => mockGetRecommendation(...args), -})); - -const mockFetchSessionSpans = vi.fn(); -vi.mock('../fetch-session-spans', () => ({ - fetchSessionSpans: (...args: unknown[]) => mockFetchSessionSpans(...args), -})); - -const mockReadFileSync = vi.fn(); -vi.mock('fs', async () => { - const actual = await vi.importActual('fs'); - return { ...actual, readFileSync: (...args: unknown[]) => mockReadFileSync(...args) }; -}); - -describe('runRecommendationCommand', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns error when agent is not deployed', async () => { - mockReadDeployedState.mockResolvedValueOnce({ targets: {} }); - - const result = await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'NonExistentAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'You are helpful.', - traceSource: 'cloudwatch', - }); - - assert(!result.success); - expect(result.error.message).toContain('NonExistentAgent'); - expect(result.error.message).toContain('not deployed'); - }); - - it('returns error when evaluator cannot be resolved', async () => { - const result = await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['UnknownEvaluator'], - inputSource: 'inline', - inlineContent: 'You are helpful.', - traceSource: 'cloudwatch', - }); - - assert(!result.success); - expect(result.error.message).toContain('UnknownEvaluator'); - expect(result.error.message).toContain('not found'); - }); - - it('returns result on COMPLETED status', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-001', - recommendationArn: 'arn:rec-001', - name: 'test-rec', - type: 'SYSTEM_PROMPT_RECOMMENDATION', - status: 'PENDING', - }); - - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-001', - status: 'COMPLETED', - createdAt: '2026-03-30T00:00:00Z', - completedAt: '2026-03-30T00:01:00Z', - recommendationResult: { - systemPromptRecommendationResult: { - recommendedSystemPrompt: 'Optimized prompt', - explanation: 'Made clearer', - }, - }, - }); - - const result = await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'You are helpful.', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - assert(result.success); - expect(result.recommendationId).toBe('rec-001'); - expect(result.status).toBe('COMPLETED'); - expect(result.result?.systemPromptRecommendationResult?.recommendedSystemPrompt).toBe('Optimized prompt'); - }); - - it('returns error on FAILED status', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-002', - recommendationArn: 'arn:rec-002', - name: 'test-rec', - type: 'SYSTEM_PROMPT_RECOMMENDATION', - status: 'PENDING', - }); - - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-002', - status: 'FAILED', - }); - - const result = await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'You are helpful.', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - assert(!result.success); - expect(result.error.message).toContain('FAILED'); - expect(result.recommendationId).toBe('rec-002'); - }); - - it('expands Builtin.* evaluator to full ARN in startRecommendation call', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-003', - status: 'COMPLETED', - }); - - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-003', - status: 'COMPLETED', - recommendationResult: {}, - }); - - await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const evaluators = callArgs.recommendationConfig.systemPromptRecommendationConfig.evaluationConfig.evaluators; - expect(evaluators[0].evaluatorArn).toBe('arn:aws:bedrock-agentcore:::evaluator/Builtin.Toxicity'); - }); - - it('uses account ID from runtime ARN in log group ARN', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-004', - status: 'COMPLETED', - }); - - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-004', - status: 'COMPLETED', - recommendationResult: {}, - }); - - await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const logGroupArn = - callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces.cloudwatchLogs.logGroupArns[0]; - expect(logGroupArn).toContain(':998846730471:'); - expect(logGroupArn).not.toContain(':*:'); - }); - - it('resolves custom evaluator from deployed state', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-005', - status: 'COMPLETED', - }); - - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-005', - status: 'COMPLETED', - recommendationResult: {}, - }); - - await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['MyEvaluator'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const evaluators = callArgs.recommendationConfig.systemPromptRecommendationConfig.evaluationConfig.evaluators; - expect(evaluators[0].evaluatorArn).toBe( - 'arn:aws:bedrock-agentcore:us-east-1:998846730471:evaluator/my-eval-abc1234567' - ); - }); - - it('builds TOOL_DESCRIPTION_RECOMMENDATION config with toolName:description pairs', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-006', - status: 'COMPLETED', - }); - - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-006', - status: 'COMPLETED', - recommendationResult: {}, - }); - - await runRecommendationCommand({ - type: 'TOOL_DESCRIPTION_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - tools: ['search:Search the web for info', 'calculate:Perform math calculations'], - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const tools = - callArgs.recommendationConfig.toolDescriptionRecommendationConfig.toolDescription.toolDescriptionText.tools; - expect(tools).toHaveLength(2); - expect(tools[0].toolName).toBe('search'); - expect(tools[0].toolDescription.text).toBe('Search the web for info'); - expect(tools[1].toolName).toBe('calculate'); - expect(tools[1].toolDescription.text).toBe('Perform math calculations'); - }); - - it('catches and returns errors from startRecommendation', async () => { - mockStartRecommendation.mockRejectedValue(new Error('API timeout')); - - const result = await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'cloudwatch', - }); - - assert(!result.success); - expect(result.error.message).toContain('API timeout'); - }); - - it('retries transient poll failures and succeeds', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-retry-ok', - recommendationArn: 'arn:rec-retry-ok', - name: 'test-rec', - type: 'SYSTEM_PROMPT_RECOMMENDATION', - status: 'PENDING', - }); - - // First poll fails, second succeeds - mockGetRecommendation.mockRejectedValueOnce(new Error('fetch failed')).mockResolvedValueOnce({ - recommendationId: 'rec-retry-ok', - status: 'COMPLETED', - recommendationResult: { - systemPromptRecommendationResult: { recommendedSystemPrompt: 'Better prompt' }, - }, - }); - - const result = await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - expect(result.success).toBe(true); - expect(result.recommendationId).toBe('rec-retry-ok'); - expect(mockGetRecommendation).toHaveBeenCalledTimes(2); - }); - - it('fails after max consecutive poll retries', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-retry-fail', - recommendationArn: 'arn:rec-retry-fail', - name: 'test-rec', - type: 'SYSTEM_PROMPT_RECOMMENDATION', - status: 'PENDING', - }); - - mockGetRecommendation.mockRejectedValue(new Error('fetch failed')); - - const result = await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - assert(!result.success); - expect(result.error.message).toContain('consecutive errors'); - expect(result.error.message).toContain('fetch failed'); - expect(result.error.message).toContain('rec-retry-fail'); - expect(mockGetRecommendation).toHaveBeenCalledTimes(3); - }); - - it('times out after max poll duration', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-timeout', - recommendationArn: 'arn:rec-timeout', - name: 'test-rec', - type: 'SYSTEM_PROMPT_RECOMMENDATION', - status: 'PENDING', - }); - - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-timeout', - status: 'IN_PROGRESS', - }); - - const result = await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - maxPollDurationMs: 0, // Immediately timeout - }); - - assert(!result.success); - expect(result.error.message).toContain('Polling timed out'); - expect(result.error.message).toContain('rec-timeout'); - }); - - it('reads system prompt from file when inputSource is file', async () => { - mockReadFileSync.mockReturnValue('You are a healthcare assistant.'); - - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-file', - status: 'COMPLETED', - }); - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-file', - status: 'COMPLETED', - recommendationResult: {}, - }); - - await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Helpfulness'], - inputSource: 'file', - promptFile: '/tmp/prompt.txt', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - expect(mockReadFileSync).toHaveBeenCalledWith('/tmp/prompt.txt', 'utf-8'); - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const systemPrompt = callArgs.recommendationConfig.systemPromptRecommendationConfig.systemPrompt; - expect(systemPrompt.text).toBe('You are a healthcare assistant.'); - }); - - it('uses inline sessionSpans from spans-file trace source', async () => { - const fakeSpans = [ - { traceId: 't1', spanId: 's1', body: {} }, - { traceId: 't1', spanId: 's2', body: {} }, - ]; - mockReadFileSync.mockReturnValue(JSON.stringify(fakeSpans)); - - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-spans', - status: 'COMPLETED', - }); - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-spans', - status: 'COMPLETED', - recommendationResult: {}, - }); - - await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'spans-file', - spansFile: '/tmp/spans.json', - pollIntervalMs: 0, - }); - - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const traces = callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces; - expect(traces.sessionSpans).toHaveLength(2); - expect(traces.cloudwatchLogs).toBeUndefined(); - }); - - it('wraps single span object in array for spans-file', async () => { - const singleSpan = { traceId: 't1', spanId: 's1', body: {} }; - mockReadFileSync.mockReturnValue(JSON.stringify(singleSpan)); - - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-single', - status: 'COMPLETED', - }); - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-single', - status: 'COMPLETED', - recommendationResult: {}, - }); - - await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'spans-file', - spansFile: '/tmp/single.json', - pollIntervalMs: 0, - }); - - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const traces = callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces; - expect(traces.sessionSpans).toHaveLength(1); - }); - - it('auto-fetches spans for tool-desc with sessions trace source', async () => { - mockFetchSessionSpans.mockResolvedValue({ - spans: [ - { traceId: 't1', spanId: 's1', body: {} }, - { traceId: 't1', spanId: 's2', body: {} }, - ], - spanRecordCount: 1, - logRecordCount: 1, - }); - - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-autofetch', - status: 'COMPLETED', - }); - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-autofetch', - status: 'COMPLETED', - recommendationResult: {}, - }); - - await runRecommendationCommand({ - type: 'TOOL_DESCRIPTION_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - tools: ['add_numbers:Add two numbers together'], - traceSource: 'sessions', - sessionIds: ['session-abc'], - pollIntervalMs: 0, - }); - - expect(mockFetchSessionSpans).toHaveBeenCalledWith( - expect.objectContaining({ - region: 'us-east-1', - runtimeId: 'rt-abc123', - sessionId: 'session-abc', - }) - ); - - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const traces = callArgs.recommendationConfig.toolDescriptionRecommendationConfig.agentTraces; - expect(traces.sessionSpans).toHaveLength(2); - expect(traces.cloudwatchLogs).toBeUndefined(); - }); - - it('throws when auto-fetch returns zero spans', async () => { - mockFetchSessionSpans.mockResolvedValue({ - spans: [], - spanRecordCount: 0, - logRecordCount: 0, - }); - - const result = await runRecommendationCommand({ - type: 'TOOL_DESCRIPTION_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - tools: ['add_numbers:Add numbers'], - traceSource: 'sessions', - sessionIds: ['session-empty'], - pollIntervalMs: 0, - }); - - assert(!result.success); - expect(result.error.message).toContain('No spans found'); - }); - - it('derives service name from runtimeId by stripping hash suffix', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-svc', - status: 'COMPLETED', - }); - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-svc', - status: 'COMPLETED', - recommendationResult: {}, - }); - - await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const serviceNames = - callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces.cloudwatchLogs.serviceNames; - // runtimeId 'rt-abc123' → service name 'rt.DEFAULT' (strips '-abc123' suffix) - expect(serviceNames[0]).toBe('rt.DEFAULT'); - }); - - it('auto-fetches spans for system-prompt with sessions trace source', async () => { - mockFetchSessionSpans.mockResolvedValue({ spans: [{ sessionId: 'sess-1', spans: [] }] }); - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-sid', - status: 'COMPLETED', - }); - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-sid', - status: 'COMPLETED', - recommendationResult: {}, - }); - - await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'sessions', - sessionIds: ['sess-1'], - pollIntervalMs: 0, - }); - - expect(mockFetchSessionSpans).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-1' })); - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const traces = callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces; - expect(traces.sessionSpans).toBeDefined(); - expect(traces.cloudwatchLogs).toBeUndefined(); - }); - - it('builds cloudwatch config with two log group ARNs', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-cw', - status: 'COMPLETED', - }); - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-cw', - status: 'COMPLETED', - recommendationResult: {}, - }); - - await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'cloudwatch', - lookbackDays: 3, - pollIntervalMs: 0, - }); - - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const cwConfig = callArgs.recommendationConfig.systemPromptRecommendationConfig.agentTraces.cloudwatchLogs; - expect(cwConfig.logGroupArns).toHaveLength(2); - expect(cwConfig.logGroupArns[0]).toContain('/aws/bedrock-agentcore/runtimes/rt-abc123-DEFAULT'); - expect(cwConfig.logGroupArns[1]).toContain('aws/spans'); - expect(cwConfig.startTime).toBeDefined(); - expect(cwConfig.endTime).toBeDefined(); - }); - - it('extracts failure details from statusReasons and result error fields', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-fail-detail', - recommendationArn: 'arn:rec-fail-detail', - name: 'test', - type: 'SYSTEM_PROMPT_RECOMMENDATION', - status: 'PENDING', - requestId: 'start-req-id', - }); - - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-fail-detail', - status: 'FAILED', - requestId: 'poll-req-id', - statusReasons: ['Insufficient trace data'], - recommendationResult: { - systemPromptRecommendationResult: { - errorCode: 'INSUFFICIENT_DATA', - errorMessage: 'Not enough traces to generate recommendation', - }, - }, - }); - - const result = await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: ['Builtin.Toxicity'], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - assert(!result.success); - expect(result.error.message).toContain('Insufficient trace data'); - expect(result.error.message).toContain('INSUFFICIENT_DATA'); - expect(result.error.message).toContain('Not enough traces'); - // Request IDs are logged to file only, not included in the error message - }); - - it('passes full ARN evaluator as-is', async () => { - mockStartRecommendation.mockResolvedValue({ - recommendationId: 'rec-arn', - status: 'COMPLETED', - }); - mockGetRecommendation.mockResolvedValue({ - recommendationId: 'rec-arn', - status: 'COMPLETED', - recommendationResult: {}, - }); - - const fullArn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:evaluator/custom-eval'; - await runRecommendationCommand({ - type: 'SYSTEM_PROMPT_RECOMMENDATION', - agent: 'MyAgent', - evaluators: [fullArn], - inputSource: 'inline', - inlineContent: 'test', - traceSource: 'cloudwatch', - pollIntervalMs: 0, - }); - - const callArgs = mockStartRecommendation.mock.calls[0]![0]; - const evaluators = callArgs.recommendationConfig.systemPromptRecommendationConfig.evaluationConfig.evaluators; - expect(evaluators[0].evaluatorArn).toBe(fullArn); - }); -}); diff --git a/src/cli/operations/recommendation/constants.ts b/src/cli/operations/recommendation/constants.ts deleted file mode 100644 index c79647c44..000000000 --- a/src/cli/operations/recommendation/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** Polling interval in ms for checking recommendation status. */ -export const DEFAULT_POLL_INTERVAL_MS = 5000; - -/** Statuses that indicate a recommendation has reached a terminal state. */ -export const TERMINAL_STATUSES = new Set(['COMPLETED', 'SUCCEEDED', 'FAILED', 'DELETING']); - -/** Max retries for transient poll failures (network errors, 5xx). */ -export const MAX_POLL_RETRIES = 3; - -/** Max total polling duration in ms (30 minutes). */ -export const MAX_POLL_DURATION_MS = 30 * 60 * 1000; diff --git a/src/cli/operations/recommendation/index.ts b/src/cli/operations/recommendation/index.ts deleted file mode 100644 index f60a1d798..000000000 --- a/src/cli/operations/recommendation/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { applyRecommendationToBundle } from './apply-to-bundle'; -export type { ApplyRecommendationOptions, ApplyRecommendationResult } from './apply-to-bundle'; -export { fetchSessionSpans } from './fetch-session-spans'; -export type { FetchSessionSpansOptions, FetchSessionSpansResult } from './fetch-session-spans'; -export { runRecommendationCommand } from './run-recommendation'; -export type { - RunRecommendationCommandOptions, - RunRecommendationCommandResult, - RecommendationType, - RecommendationInputSourceKind, - TraceSourceKind, -} from './types'; -export { - saveRecommendationRun, - loadRecommendationRun, - listAllRecommendations, - type RecommendationRunRecord, -} from './recommendation-storage'; diff --git a/src/cli/operations/recommendation/recommendation-storage.ts b/src/cli/operations/recommendation/recommendation-storage.ts deleted file mode 100644 index 2049535e3..000000000 --- a/src/cli/operations/recommendation/recommendation-storage.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { findConfigRoot } from '../../../lib'; -import type { RecommendationResult, RecommendationType } from '../../aws/agentcore-recommendation'; -import type { RunRecommendationCommandResult } from './types'; -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'; -import { join } from 'path'; - -export const RECOMMENDATIONS_DIR = 'recommendations'; - -export interface RecommendationRunRecord { - recommendationId: string; - type: RecommendationType; - agent: string; - evaluators: string[]; - status: string; - startedAt?: string; - completedAt?: string; - result?: RecommendationResult; -} - -function getRecommendationResultsDir(): string { - const configRoot = findConfigRoot(); - if (!configRoot) { - throw new Error('No agentcore project found. Run `agentcore create` first.'); - } - return join(configRoot, '.cli', RECOMMENDATIONS_DIR); -} - -export function saveRecommendationRun( - recommendationId: string, - result: RunRecommendationCommandResult, - type: RecommendationType, - agent: string, - evaluators: string[] -): string { - const dir = getRecommendationResultsDir(); - mkdirSync(dir, { recursive: true }); - - const filePath = join(dir, `${recommendationId}.json`); - - const record: RecommendationRunRecord = { - recommendationId, - type, - agent, - evaluators, - status: result.status ?? 'unknown', - startedAt: result.success ? result.startedAt : undefined, - completedAt: result.success ? result.completedAt : undefined, - result: result.success ? result.result : undefined, - }; - - writeFileSync(filePath, JSON.stringify(record, null, 2)); - return filePath; -} - -export function loadRecommendationRun(recommendationId: string): RecommendationRunRecord { - const dir = getRecommendationResultsDir(); - const jsonName = recommendationId.endsWith('.json') ? recommendationId : `${recommendationId}.json`; - const filePath = join(dir, jsonName); - - if (!existsSync(filePath)) { - throw new Error(`Recommendation "${recommendationId}" not found at ${filePath}`); - } - - return JSON.parse(readFileSync(filePath, 'utf-8')) as RecommendationRunRecord; -} - -export function listAllRecommendations(): RecommendationRunRecord[] { - const configRoot = findConfigRoot(); - if (!configRoot) { - throw new Error('No agentcore project found. Run `agentcore create` first.'); - } - - const dir = join(configRoot, '.cli', RECOMMENDATIONS_DIR); - if (!existsSync(dir)) { - return []; - } - - const files = readdirSync(dir) - .filter(f => f.endsWith('.json')) - .sort() - .reverse(); - - return files.map(f => JSON.parse(readFileSync(join(dir, f), 'utf-8')) as RecommendationRunRecord); -} diff --git a/src/cli/operations/recommendation/run-recommendation.ts b/src/cli/operations/recommendation/run-recommendation.ts deleted file mode 100644 index 42ff863cc..000000000 --- a/src/cli/operations/recommendation/run-recommendation.ts +++ /dev/null @@ -1,623 +0,0 @@ -/** - * Orchestrates running a Recommendation: - * 1. Resolve agent and evaluator from project - * 2. Build recommendationConfig from CLI inputs - * 3. Call StartRecommendation (creates resource, returns 202) - * 4. Poll GetRecommendation until terminal status - * 5. Return result with optimized artifact - */ -import { ConfigIO, ResourceNotFoundError, TimeoutError, ValidationError, toError } from '../../../lib'; -import type { DeployedState } from '../../../schema'; -import type { - RecommendationConfig, - RecommendationResult, - RecommendationType, - SessionSpan, -} from '../../aws/agentcore-recommendation'; -import { getRecommendation, startRecommendation } from '../../aws/agentcore-recommendation'; -import { runtimeLogGroup } from '../../aws/cloudwatch'; -import { arnPrefix } from '../../aws/partition'; -import { detectRegion } from '../../aws/region'; -import { ExecLogger } from '../../logging/exec-logger'; -import { DEFAULT_POLL_INTERVAL_MS, MAX_POLL_DURATION_MS, MAX_POLL_RETRIES, TERMINAL_STATUSES } from './constants'; -import { fetchSessionSpans } from './fetch-session-spans'; -import type { RunRecommendationCommandOptions, RunRecommendationCommandResult } from './types'; -import { readFileSync } from 'fs'; - -export async function runRecommendationCommand( - options: RunRecommendationCommandOptions -): Promise { - const { pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, onProgress } = options; - let logger: ExecLogger | undefined; - try { - logger = new ExecLogger({ command: 'recommend' }); - } catch { - // Logger creation can fail in tests or when no project root exists — non-fatal - } - - try { - logger?.startStep('Load project config'); - // 1. Read project config and deployed state - const configIO = new ConfigIO(); - const [projectSpec, deployedState, awsTargets] = await Promise.all([ - configIO.readProjectSpec(), - configIO.readDeployedState(), - configIO.resolveAWSDeploymentTargets(), - ]); - - const targetRegion = awsTargets.length > 0 ? awsTargets[0]!.region : undefined; - const { region: detectedRegion } = await detectRegion(); - const region = options.region ?? targetRegion ?? detectedRegion; - const stage = process.env.AGENTCORE_STAGE?.toLowerCase() ?? 'prod'; - logger?.log(`Region: ${region}, Stage: ${stage}`); - logger?.endStep('success'); - - // 2. Resolve agent from deployed state (needed for log group ARNs) - logger?.startStep('Resolve agent and evaluators'); - const agentState = resolveAgentState(deployedState, options.agent); - if (!agentState) { - logger?.log(`Agent "${options.agent}" not found in deployed state`, 'error'); - logger?.endStep('error', `Agent "${options.agent}" not deployed`); - logger?.finalize(false); - return { - success: false, - error: new Error(`Agent "${options.agent}" not deployed. Run \`agentcore deploy\` first.`), - logFilePath: logger?.logFilePath, - }; - } - logger?.log(`Agent: ${options.agent} (runtime: ${agentState.runtimeId})`); - - // 3. Resolve evaluator ID/ARN (API accepts exactly one for system-prompt, none for tool-desc) - const evaluatorIds: string[] = []; - for (const evaluator of options.evaluators) { - const evaluatorId = resolveEvaluatorId(deployedState, evaluator, region); - if (!evaluatorId) { - return { - success: false, - error: new Error( - `Evaluator "${evaluator}" not found in deployed state. Use a Builtin.* name, a full ARN, or deploy a custom evaluator first.` - ), - logFilePath: logger?.logFilePath, - }; - } - evaluatorIds.push(evaluatorId); - } - if (options.type === 'SYSTEM_PROMPT_RECOMMENDATION' && evaluatorIds.length !== 1) { - return { - success: false, - error: new ValidationError('System prompt recommendations require exactly one evaluator.'), - logFilePath: logger?.logFilePath, - }; - } - logger?.log(`Evaluators: ${evaluatorIds.join(', ') || '(none)'}`); - logger?.endStep('success'); - - // 4. Read input content (if from file) - let inlineContent: string | undefined; - if (options.inputSource === 'file' && options.promptFile) { - inlineContent = readFileSync(options.promptFile, 'utf-8'); - } else if (options.inputSource === 'inline') { - inlineContent = options.inlineContent; - } - - // Validate that system prompt content is non-empty (API rejects empty text) - if ( - options.type === 'SYSTEM_PROMPT_RECOMMENDATION' && - options.inputSource !== 'config-bundle' && - !inlineContent?.trim() - ) { - return { - success: false, - error: new ValidationError( - 'System prompt content is required. Provide via --inline, --prompt-file, or --bundle-name.' - ), - logFilePath: logger?.logFilePath, - }; - } - - // 5. Extract account ID from agent runtime ARN - const accountId = extractAccountIdFromArn(agentState.runtimeArn); - - // 5b. Resolve config bundle ARN from deployed state (if using config bundle) - let bundleArn: string | undefined; - if (options.inputSource === 'config-bundle' && options.bundleName) { - if (options.bundleName.startsWith('arn:')) { - // Already an ARN (e.g. from TUI which stores the ARN directly) - bundleArn = options.bundleName; - } else { - // Human-readable name (e.g. from CLI --bundle-name flag) — resolve from deployed state - for (const targetName of Object.keys(deployedState.targets ?? {})) { - const target = deployedState.targets?.[targetName]; - const bundle = target?.resources?.configBundles?.[options.bundleName]; - if (bundle?.bundleArn) { - bundleArn = bundle.bundleArn; - break; - } - } - if (!bundleArn) { - return { - success: false, - error: new ResourceNotFoundError( - `Config bundle "${options.bundleName}" not found in deployed state. Run \`agentcore deploy\` first.` - ), - logFilePath: logger?.logFilePath, - }; - } - } - logger?.log(`Resolved bundle ARN: ${bundleArn}`); - } - - // 5c. Resolve short-form systemPromptJsonPath (e.g. "systemPrompt") to full JSONPath - let resolvedSystemPromptJsonPath = options.systemPromptJsonPath; - if ( - options.inputSource === 'config-bundle' && - options.bundleName && - resolvedSystemPromptJsonPath && - !resolvedSystemPromptJsonPath.startsWith('$') - ) { - // User provided a short field name like "systemPrompt" — resolve from agentcore.json - const bundleName = options.bundleName.startsWith('arn:') - ? // Find bundle name from ARN by matching deployed state - Object.values(deployedState.targets) - .flatMap(t => Object.entries(t.resources?.configBundles ?? {})) - .find(([, b]) => b.bundleArn === options.bundleName)?.[0] - : options.bundleName; - - if (bundleName) { - const projBundle = projectSpec.configBundles?.find(b => b.name === bundleName); - if (projBundle?.components) { - const subPath = resolvedSystemPromptJsonPath; - // Use the first component key, resolved to a real ARN - const firstComponentKey = Object.keys(projBundle.components)[0]; - if (firstComponentKey) { - const resolvedKey = resolveComponentKeyForJsonPath(firstComponentKey, deployedState); - resolvedSystemPromptJsonPath = `$.${resolvedKey}.configuration.${subPath}`; - logger?.log(`Resolved short JSONPath "${subPath}" → "${resolvedSystemPromptJsonPath}"`); - } - } - } - } - - // 6. Build recommendationConfig based on type - const recommendationConfig = await buildRecommendationConfig({ - type: options.type, - inlineContent, - bundleArn, - bundleVersion: options.bundleVersion, - systemPromptJsonPath: resolvedSystemPromptJsonPath, - toolDescJsonPaths: options.toolDescJsonPaths, - inputSource: options.inputSource, - tools: options.tools, - traceSource: options.traceSource, - lookbackDays: options.lookbackDays, - sessionIds: options.sessionIds, - spansFile: options.spansFile, - runtimeId: agentState.runtimeId, - accountId, - region, - evaluatorIds, - onProgress, - logger, - }); - - // 7. Start the recommendation - logger?.startStep('Start recommendation'); - const recommendationName = options.recommendationName ?? `${projectSpec.name}_${options.agent}_${Date.now()}`; - onProgress?.('starting', `Starting recommendation "${recommendationName}"...`); - - const startPayload = { - region, - name: recommendationName, - type: options.type, - recommendationConfig, - }; - logger?.log(`Request payload:\n${JSON.stringify(startPayload, null, 2)}`); - - const startResult = await startRecommendation(startPayload); - - logger?.log(`Response: ${JSON.stringify(startResult, null, 2)}`); - logger?.endStep('success'); - onProgress?.('started', `Recommendation created: ${startResult.recommendationId} (status: ${startResult.status})`); - options.onStarted?.({ recommendationId: startResult.recommendationId, region }); - - // 8. Poll GetRecommendation until terminal status - logger?.startStep('Poll for completion'); - const maxDurationMs = options.maxPollDurationMs ?? MAX_POLL_DURATION_MS; - const pollStartTime = Date.now(); - let currentStatus = startResult.status; - let consecutiveFailures = 0; - - while (!TERMINAL_STATUSES.has(currentStatus)) { - await sleep(pollIntervalMs); - - // Check max poll duration - if (Date.now() - pollStartTime > maxDurationMs) { - logger?.log(`Max poll duration (${maxDurationMs}ms) exceeded`, 'error'); - logger?.endStep('error', 'Poll timeout'); - logger?.finalize(false); - return { - success: false, - error: new TimeoutError( - `Polling timed out after ${Math.round(maxDurationMs / 60000)} minutes. The recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}` - ), - recommendationId: startResult.recommendationId, - status: currentStatus, - logFilePath: logger?.logFilePath, - }; - } - - // Poll with retry for transient failures - let pollResult; - try { - pollResult = await getRecommendation({ - region, - recommendationId: startResult.recommendationId, - }); - consecutiveFailures = 0; - } catch (pollErr) { - consecutiveFailures++; - const pollErrMsg = pollErr instanceof Error ? pollErr.message : String(pollErr); - logger?.log(`Poll attempt failed (${consecutiveFailures}/${MAX_POLL_RETRIES}): ${pollErrMsg}`, 'error'); - - if (consecutiveFailures >= MAX_POLL_RETRIES) { - logger?.endStep('error', `${MAX_POLL_RETRIES} consecutive poll failures`); - logger?.finalize(false); - return { - success: false, - error: new TimeoutError( - `Polling failed after ${MAX_POLL_RETRIES} consecutive errors: ${pollErrMsg}\nThe recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}` - ), - recommendationId: startResult.recommendationId, - status: currentStatus, - logFilePath: logger?.logFilePath, - }; - } - onProgress?.('polling', `Poll error, retrying (${consecutiveFailures}/${MAX_POLL_RETRIES})...`); - continue; - } - - currentStatus = pollResult.status; - onProgress?.('polling', `Status: ${currentStatus}`); - - if (TERMINAL_STATUSES.has(currentStatus)) { - if (currentStatus === 'COMPLETED' || currentStatus === 'SUCCEEDED') { - logger?.log(`Completed. Result:\n${JSON.stringify(pollResult.recommendationResult, null, 2)}`); - logger?.endStep('success'); - logger?.finalize(true); - return { - success: true, - recommendationId: startResult.recommendationId, - status: currentStatus, - result: pollResult.recommendationResult, - region, - startedAt: pollResult.createdAt, - completedAt: pollResult.completedAt, - logFilePath: logger?.logFilePath, - }; - } - - // Extract error details from the FAILED response - const failureDetails = extractFailureDetails(pollResult); - logger?.log(`Terminal status: ${currentStatus}`, 'error'); - logger?.log(`Full poll response:\n${JSON.stringify(pollResult, null, 2)}`, 'error'); - if (failureDetails) logger?.log(`Failure details: ${failureDetails}`, 'error'); - logger?.endStep('error', `Status: ${currentStatus}`); - logger?.finalize(false); - // Log request IDs for debugging (only in log file, not shown in TUI) - const requestIds = [ - startResult.requestId ? `Start: ${startResult.requestId}` : '', - pollResult.requestId ? `Poll: ${pollResult.requestId}` : '', - ] - .filter(Boolean) - .join(', '); - if (requestIds) logger?.log(`Request IDs: ${requestIds}`, 'error'); - - return { - success: false, - error: new Error( - failureDetails - ? `Recommendation failed: ${failureDetails}` - : `Recommendation finished with status: ${currentStatus}` - ), - recommendationId: startResult.recommendationId, - status: currentStatus, - logFilePath: logger?.logFilePath, - }; - } - } - - // Should not reach here, but handle gracefully - logger?.log(`Unexpected terminal status: ${currentStatus}`, 'error'); - logger?.endStep('error', `Unexpected status: ${currentStatus}`); - logger?.finalize(false); - return { - success: false, - error: new Error(`Recommendation ended with unexpected status: ${currentStatus}`), - recommendationId: startResult.recommendationId, - status: currentStatus, - logFilePath: logger?.logFilePath, - }; - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err); - logger?.log(`Error: ${errorMsg}`, 'error'); - logger?.endStep('error', errorMsg); - logger?.finalize(false); - return { - success: false, - error: toError(err), - logFilePath: logger?.logFilePath, - }; - } -} - -// ============================================================================ -// Helpers -// ============================================================================ - -function resolveAgentState( - deployedState: DeployedState, - agentName: string -): { runtimeId: string; runtimeArn: string } | undefined { - for (const target of Object.values(deployedState.targets)) { - const agent = target.resources?.runtimes?.[agentName]; - if (agent) return agent; - } - return undefined; -} - -/** - * Resolve an evaluator name to a full ARN. - * Returns undefined if the evaluator cannot be resolved. - */ -function resolveEvaluatorId(deployedState: DeployedState, evaluator: string, region: string): string | undefined { - // Already a full ARN — use as-is - if (evaluator.startsWith('arn:')) { - return evaluator; - } - // Builtin shorthand → expand to full ARN - if (evaluator.startsWith('Builtin.')) { - return `${arnPrefix(region)}:bedrock-agentcore:::evaluator/${evaluator}`; - } - // Look up custom evaluator from deployed state - for (const target of Object.values(deployedState.targets)) { - const evalState = target.resources?.evaluators?.[evaluator]; - if (evalState) return evalState.evaluatorArn; - } - return undefined; -} - -/** - * Extract the 12-digit AWS account ID from an ARN. - * Falls back to '*' if the ARN format is unexpected. - */ -function extractAccountIdFromArn(arn: string): string { - const parts = arn.split(':'); - return parts[4] && /^\d{12}$/.test(parts[4]) ? parts[4] : '*'; -} - -interface BuildConfigOptions { - type: RecommendationType; - inlineContent?: string; - bundleArn?: string; - bundleVersion?: string; - systemPromptJsonPath?: string; - toolDescJsonPaths?: { toolName: string; toolDescriptionJsonPath: string }[]; - inputSource: string; - tools?: string[]; - traceSource: string; - lookbackDays?: number; - sessionIds?: string[]; - spansFile?: string; - runtimeId: string; - accountId: string; - region: string; - evaluatorIds: string[]; - onProgress?: (status: string, message: string) => void; - logger?: ExecLogger; -} - -async function buildRecommendationConfig(opts: BuildConfigOptions): Promise { - // Build agent traces — either from a spans file (inline session spans) or CloudWatch - let agentTraces; - - if (opts.traceSource === 'spans-file' && opts.spansFile) { - // Explicit spans file — read and use as inline sessionSpans - const spansContent = readFileSync(opts.spansFile, 'utf-8'); - const sessionSpans = JSON.parse(spansContent) as SessionSpan | SessionSpan[]; - agentTraces = { - sessionSpans: Array.isArray(sessionSpans) ? sessionSpans : [sessionSpans], - }; - } else if (opts.traceSource === 'sessions' && opts.sessionIds && opts.sessionIds.length > 0) { - // Session IDs selected — auto-fetch from both log groups and use inline sessionSpans. - // The CloudWatch trace config does not support filtering by multiple session IDs, - // so we fetch spans client-side and send them inline. - opts.onProgress?.('fetching-spans', 'Fetching session spans from CloudWatch...'); - opts.logger?.log( - 'Auto-fetching spans for selected sessions (CloudWatch config does not support session ID filtering)' - ); - - const allSpans = []; - for (const sessionId of opts.sessionIds) { - const result = await fetchSessionSpans({ - region: opts.region, - runtimeId: opts.runtimeId, - sessionId, - lookbackDays: opts.lookbackDays ?? 7, - onProgress: msg => { - opts.logger?.log(msg); - opts.onProgress?.('fetching-spans', msg); - }, - }); - allSpans.push(...result.spans); - } - - if (allSpans.length === 0) { - throw new Error( - 'No spans found for the specified session(s). Ensure the agent has been invoked and traces have propagated to CloudWatch (may take 5-10 minutes).' - ); - } - - opts.logger?.log(`Total spans fetched: ${allSpans.length}`); - opts.onProgress?.('fetching-spans', `Fetched ${allSpans.length} spans`); - agentTraces = { sessionSpans: allSpans }; - } else { - // Lookback-based path — use cloudwatchLogs with time range - const runtimeLogGroupArn = `${arnPrefix(opts.region)}:logs:${opts.region}:${opts.accountId}:log-group:${runtimeLogGroup(opts.runtimeId)}`; - const spansLogGroupArn = `${arnPrefix(opts.region)}:logs:${opts.region}:${opts.accountId}:log-group:aws/spans`; - - // Derive service name: strip the random hash suffix from runtimeId - // runtimeId format: {project}_{agent}-{hash} → serviceName: {project}_{agent}.DEFAULT - const serviceName = opts.runtimeId.replace(/-[^-]+$/, '.DEFAULT'); - - const lookbackDays = opts.lookbackDays ?? 7; - agentTraces = { - cloudwatchLogs: { - logGroupArns: [runtimeLogGroupArn, spansLogGroupArn], - serviceNames: [serviceName], - startTime: new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString(), - endTime: new Date().toISOString(), - }, - }; - } - - const evaluationConfig: import('../../aws/agentcore-recommendation').RecommendationEvaluationConfig = { - evaluators: [{ evaluatorArn: opts.evaluatorIds[0]! }], - }; - - // Validate required fields for config-bundle source (API requires all three) - if (opts.inputSource === 'config-bundle' && opts.bundleArn && !opts.bundleVersion) { - throw new Error('Config bundle version is required. Provide --bundle-version or deploy the bundle first.'); - } - - if (opts.inputSource === 'config-bundle' && opts.bundleArn) { - if (opts.type === 'SYSTEM_PROMPT_RECOMMENDATION' && !opts.systemPromptJsonPath) { - throw new Error( - 'Config bundle requires --system-prompt-json-path to locate the system prompt field.\n' + - "Use the field name (e.g. --system-prompt-json-path 'systemPrompt') and it will be resolved from agentcore.json.\n" + - "Or provide the full JSONPath (e.g. '$.ARN.configuration.systemPrompt')." - ); - } - if (opts.type === 'TOOL_DESCRIPTION_RECOMMENDATION' && !opts.toolDescJsonPaths?.length) { - throw new Error( - 'Config bundle requires --tool-desc-json-path to locate tool description fields.\n' + - "Example: --tool-desc-json-path 'toolName:$.ARN.configuration.toolDescription'" - ); - } - } - - if (opts.type === 'SYSTEM_PROMPT_RECOMMENDATION') { - return { - systemPromptRecommendationConfig: { - systemPrompt: - opts.inputSource === 'config-bundle' && opts.bundleArn - ? { - configurationBundle: { - bundleArn: opts.bundleArn, - versionId: opts.bundleVersion!, - systemPromptJsonPath: opts.systemPromptJsonPath, - }, - } - : { text: opts.inlineContent ?? '' }, - agentTraces, - evaluationConfig, - }, - }; - } - - // TOOL_DESCRIPTION_RECOMMENDATION - if (opts.inputSource === 'config-bundle' && opts.bundleArn && opts.toolDescJsonPaths?.length) { - // Config bundle source — pass bundle reference with JSON paths for server-side resolution - return { - toolDescriptionRecommendationConfig: { - toolDescription: { - configurationBundle: { - bundleArn: opts.bundleArn, - versionId: opts.bundleVersion!, - tools: opts.toolDescJsonPaths, - }, - }, - agentTraces, - }, - }; - } - - // Inline/file source — parse "toolName:description" pairs from tools array - const toolEntries = (opts.tools ?? []).map(t => { - const colonIdx = t.indexOf(':'); - if (colonIdx > 0) { - return { toolName: t.slice(0, colonIdx), toolDescription: { text: t.slice(colonIdx + 1) } }; - } - return { toolName: t, toolDescription: { text: opts.inlineContent ?? '' } }; - }); - - return { - toolDescriptionRecommendationConfig: { - toolDescription: { - toolDescriptionText: { - tools: toolEntries, - }, - }, - agentTraces, - }, - }; -} - -/** - * Extract error details from a FAILED recommendation response. - * The API populates errorCode/errorMessage in the result, and statusReasons at top level. - */ -function extractFailureDetails(pollResult: { - statusReasons?: string[]; - recommendationResult?: RecommendationResult; -}): string | undefined { - const parts: string[] = []; - - if (pollResult.statusReasons?.length) { - parts.push(pollResult.statusReasons.join('; ')); - } - - const result = pollResult.recommendationResult; - if (result) { - const errorSource = result.systemPromptRecommendationResult ?? result.toolDescriptionRecommendationResult; - if (errorSource) { - if (errorSource.errorCode) parts.push(`[${errorSource.errorCode}]`); - if (errorSource.errorMessage) parts.push(errorSource.errorMessage); - } - } - - return parts.length > 0 ? parts.join(' ') : undefined; -} - -/** - * Resolve a component key (which may be a placeholder like {{runtime:name}}) - * to its real ARN from deployed state. Returns the key unchanged if not a placeholder. - */ -function resolveComponentKeyForJsonPath(key: string, deployedState: DeployedState): string { - if (key.startsWith('arn:')) return key; - - const rtMatch = /^\{\{runtime:(.+)\}\}$/.exec(key); - if (rtMatch) { - const rtName = rtMatch[1]!; - for (const target of Object.values(deployedState.targets)) { - const rt = target.resources?.runtimes?.[rtName]; - if (rt) return rt.runtimeArn; - } - } - - const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(key); - if (gwMatch) { - const gwName = gwMatch[1]!; - for (const target of Object.values(deployedState.targets)) { - const httpGw = target.resources?.httpGateways?.[gwName]; - if (httpGw) return httpGw.gatewayArn; - const mcpGw = target.resources?.mcp?.gateways?.[gwName]; - if (mcpGw) return mcpGw.gatewayArn; - } - } - - return key; -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} diff --git a/src/cli/operations/recommendation/types.ts b/src/cli/operations/recommendation/types.ts deleted file mode 100644 index 487681a2e..000000000 --- a/src/cli/operations/recommendation/types.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Shared types for the recommendation feature. - */ -import type { Result } from '../../../lib/result'; -import type { RecommendationResult, RecommendationType } from '../../aws/agentcore-recommendation'; - -export type { RecommendationType } from '../../aws/agentcore-recommendation'; - -/** CLI-facing input source kind (maps to API config shape). */ -export type RecommendationInputSourceKind = 'config-bundle' | 'inline' | 'file'; - -/** CLI-facing trace source kind (maps to API agentTraces shape). */ -export type TraceSourceKind = 'cloudwatch' | 'sessions' | 'spans-file'; - -export interface RunRecommendationCommandOptions { - /** What to optimize */ - type: RecommendationType; - /** Agent name (from project) */ - agent: string; - /** Evaluator name, Builtin.* ID, or ARN (API accepts exactly one for system-prompt) */ - evaluators: string[]; - /** Input source kind */ - inputSource: RecommendationInputSourceKind; - /** Config bundle name (when inputSource is 'config-bundle') */ - bundleName?: string; - /** Config bundle version (when inputSource is 'config-bundle') */ - bundleVersion?: string; - /** JSONPath to the system prompt field within the config bundle (when inputSource is 'config-bundle') */ - systemPromptJsonPath?: string; - /** Tool name → JSONPath pairs for tool descriptions within the config bundle (when inputSource is 'config-bundle') */ - toolDescJsonPaths?: { toolName: string; toolDescriptionJsonPath: string }[]; - /** Inline content (when inputSource is 'inline') */ - inlineContent?: string; - /** File path (when inputSource is 'file') */ - promptFile?: string; - /** Specific tool names and descriptions (for TOOL_DESCRIPTION_RECOMMENDATION) */ - tools?: string[]; - /** Trace source kind */ - traceSource: TraceSourceKind; - /** Lookback days (when traceSource is 'cloudwatch') */ - lookbackDays?: number; - /** Session IDs (when traceSource is 'sessions') — used to filter CloudWatch traces */ - sessionIds?: string[]; - /** Path to JSON file containing session spans (when traceSource is 'spans-file') */ - spansFile?: string; - /** Region override */ - region?: string; - /** Optional recommendation name */ - recommendationName?: string; - /** Poll interval in ms */ - pollIntervalMs?: number; - /** Max polling duration in ms before timing out */ - maxPollDurationMs?: number; - /** Progress callback */ - onProgress?: (status: string, message: string) => void; - /** Called once the recommendation has been created, with ID and region for cancellation */ - onStarted?: (info: { recommendationId: string; region: string }) => void; -} - -export type RunRecommendationCommandResult = Result<{ - result?: RecommendationResult; - region?: string; - startedAt?: string; - completedAt?: string; -}> & { recommendationId?: string; status?: string; logFilePath?: string }; diff --git a/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts b/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts index ece57dcb9..0d1ef6042 100644 --- a/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts +++ b/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts @@ -47,6 +47,7 @@ const makeProject = (agentNames: string[]) => ({ managedBy: 'CDK' as const, runtimes: agentNames.map(name => ({ name })), memories: [], + knowledgeBases: [], credentials: [], }); diff --git a/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts b/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts index f34cee7f3..570b88cdf 100644 --- a/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts +++ b/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts @@ -45,6 +45,7 @@ const makeProject = ( managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: credNames.map(name => ({ name, authorizerType: 'ApiKeyCredentialProvider' })), agentCoreGateways, }); diff --git a/src/cli/primitives/ABTestPrimitive.ts b/src/cli/primitives/ABTestPrimitive.ts deleted file mode 100644 index 7dc2a7fd4..000000000 --- a/src/cli/primitives/ABTestPrimitive.ts +++ /dev/null @@ -1,732 +0,0 @@ -import { ResourceNotFoundError, findConfigRoot, serializeResult, toError } from '../../lib'; -import type { Result } from '../../lib/result'; -import type { ABTest } from '../../schema/schemas/primitives/ab-test'; -import { ABTestSchema } from '../../schema/schemas/primitives/ab-test'; -import { getErrorMessage } from '../errors'; -import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; -import { withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; -import { requireTTY } from '../tui/guards/tty'; -import { BasePrimitive } from './BasePrimitive'; -import type { AddResult, AddScreenComponent, RemovableResource } from './types'; -import type { Command } from '@commander-js/extra-typings'; - -export type GatewayChoice = { type: 'create-new' } | { type: 'existing-http'; name: string }; - -export interface AddABTestOptions { - name: string; - description?: string; - agent: string; - gatewayChoice?: GatewayChoice; - roleArn?: string; - controlBundle: string; - controlVersion: string; - treatmentBundle: string; - treatmentVersion: string; - controlWeight: number; - treatmentWeight: number; - onlineEval: string; - trafficHeaderName?: string; - maxDurationDays?: number; - enableOnCreate?: boolean; -} - -export interface AddTargetBasedABTestOptions { - name: string; - description?: string; - gateway: string; - runtime: string; - roleArn?: string; - controlEndpoint: string; - treatmentEndpoint: string; - controlWeight: number; - treatmentWeight: number; - controlOnlineEval: string; - treatmentOnlineEval: string; - gatewayFilter?: string; - enableOnCreate?: boolean; -} - -export type RemovableABTest = RemovableResource; - -/** - * ABTestPrimitive handles all A/B test add/remove operations. - * - * A/B tests split traffic between two config bundle versions (control vs - * treatment) through a gateway, with online evaluation tracking performance. - * They are created via direct API calls (not CloudFormation) and stored in - * agentcore.json for lifecycle management. - */ -export class ABTestPrimitive extends BasePrimitive { - readonly kind = 'ab-test' as const; - readonly label = 'AB Test'; - override readonly article = 'an'; - readonly primitiveSchema = ABTestSchema; - - async add(options: AddABTestOptions): Promise> { - try { - const abTest = await this.createABTest(options); - return { success: true, abTestName: abTest.name }; - } catch (err) { - return { success: false, error: toError(err) }; - } - } - - async remove(testName: string, options?: { deleteGateway?: boolean }): Promise { - try { - const project = await this.readProjectSpec(); - - const index = (project.abTests ?? []).findIndex(t => t.name === testName); - if (index === -1) { - return { success: false, error: new ResourceNotFoundError(`AB test "${testName}" not found.`) }; - } - - const removedTest = project.abTests[index]!; - project.abTests.splice(index, 1); - - // Cascade: remove auto-created online eval configs for target-based tests - // Only remove eval configs that were auto-created (matching the {testName}_eval_ prefix pattern) - if (removedTest.mode === 'target-based' && 'perVariantOnlineEvaluationConfig' in removedTest.evaluationConfig) { - const autoCreatedPrefix = `${testName}_eval_`; - const evalNames = removedTest.evaluationConfig.perVariantOnlineEvaluationConfig - .map(pv => pv.onlineEvaluationConfigArn) - .filter(name => name.startsWith(autoCreatedPrefix)); - project.onlineEvalConfigs = project.onlineEvalConfigs.filter(c => !evalNames.includes(c.name)); - } - - // --delete-gateway: cascade remove gateway targets and orphaned gateways - if (options?.deleteGateway && removedTest.gatewayRef) { - const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(removedTest.gatewayRef); - if (gwMatch) { - const gwName = gwMatch[1]!; - - // Remove gateway targets that were created for this AB test's variants - if (removedTest.mode === 'target-based') { - const targetNames = removedTest.variants - .map(v => v.variantConfiguration.target?.targetName) - .filter((n): n is string => !!n); - const gw = (project.httpGateways ?? []).find(g => g.name === gwName); - if (gw?.targets) { - gw.targets = gw.targets.filter(t => !targetNames.includes(t.name)); - } - } - - // Remove gateway if no other AB tests reference it - const stillReferenced = (project.abTests ?? []).some(t => { - const m = /^\{\{gateway:(.+)\}\}$/.exec(t.gatewayRef); - return m?.[1] === gwName; - }); - if (!stillReferenced) { - project.httpGateways = (project.httpGateways ?? []).filter(gw => gw.name !== gwName); - } - } - } - - await this.writeProjectSpec(project); - - return { success: true }; - } catch (err) { - return { success: false, error: toError(err) }; - } - } - - async previewRemove(testName: string): Promise { - const project = await this.readProjectSpec(); - - const abTest = (project.abTests ?? []).find(t => t.name === testName); - if (!abTest) { - throw new Error(`AB test "${testName}" not found.`); - } - - const summary: string[] = [`Removing AB test: ${testName}`]; - const schemaChanges: SchemaChange[] = []; - - const testIndex = (project.abTests ?? []).findIndex(t => t.name === testName); - const afterSpec = { - ...project, - abTests: (project.abTests ?? []).filter(t => t.name !== testName), - httpGateways: [...(project.httpGateways ?? [])], - }; - - // Check if the gateway would be orphaned - const test = (project.abTests ?? [])[testIndex]; - if (test?.gatewayRef) { - const gwMatch = /^\{\{gateway:(.+)\}\}$/.exec(test.gatewayRef); - if (gwMatch) { - const gwName = gwMatch[1]; - const otherTests = (project.abTests ?? []).filter((_, i) => i !== testIndex); - const stillReferenced = otherTests.some(t => { - const m = /^\{\{gateway:(.+)\}\}$/.exec(t.gatewayRef); - return m && m[1] === gwName; - }); - if (!stillReferenced) { - summary.push(`Also removing HTTP gateway: ${gwName} (no other AB tests reference it)`); - afterSpec.httpGateways = (project.httpGateways ?? []).filter(gw => gw.name !== gwName); - } - } - } - - schemaChanges.push({ - file: 'agentcore/agentcore.json', - before: project, - after: afterSpec, - }); - - return { summary, directoriesToDelete: [], schemaChanges }; - } - - async getRemovable(): Promise { - try { - const project = await this.readProjectSpec(); - return (project.abTests ?? []).map(t => ({ name: t.name })); - } catch { - return []; - } - } - - async getAllNames(): Promise { - try { - const project = await this.readProjectSpec(); - return (project.abTests ?? []).map(t => t.name); - } catch { - return []; - } - } - - registerCommands(addCmd: Command, removeCmd: Command): void { - const abTestCmd = addCmd - .command('ab-test') - .description('[preview] Add an A/B test to the project') - .option('--mode ', 'config-bundle (default) or target-based') - .option('--name ', 'AB test name') - .option('--description ', 'AB test description') - .option('--runtime ', 'Runtime agent to A/B test') - .option('--role-arn ', 'IAM role ARN (auto-created if not provided)') - .option('--control-bundle ', 'Control config bundle name or ARN') - .option('--control-version ', 'Control config bundle version') - .option('--treatment-bundle ', 'Treatment config bundle name or ARN') - .option('--treatment-version ', 'Treatment config bundle version') - .option('--control-endpoint ', 'Endpoint qualifier for control') - .option('--treatment-endpoint ', 'Endpoint qualifier for treatment') - .option('--control-weight ', 'Traffic weight for control (1-100)', parseInt) - .option('--treatment-weight ', 'Traffic weight for treatment (1-100)', parseInt) - .option('--gateway ', 'HTTP gateway name') - .option('--online-eval ', 'Online evaluation config name or ARN') - .option('--control-online-eval ', 'Eval config name or ARN for control') - .option('--treatment-online-eval ', 'Eval config name or ARN for treatment') - .option('--gateway-filter ', 'Path pattern for routing') - .option('--traffic-header ', 'Header name for traffic routing') - // Hidden deprecated aliases for backwards compatibility - .option('--control-qualifier ', '') - .option('--treatment-qualifier ', '') - // TODO(post-preview): Re-enable --max-duration once configurable duration is launched. - // .option('--max-duration ', 'Maximum duration in days (1-90)', parseInt) - .option('--enable', 'Enable the AB test on creation') - .option('--json', 'Output as JSON'); - - // Hide mode-specific and deprecated flags from the default options list. - // They are shown in the grouped help text below instead. - const hiddenFromDefaultHelp = new Set([ - '--runtime', - '--control-bundle', - '--control-version', - '--treatment-bundle', - '--treatment-version', - '--online-eval', - '--traffic-header', - '--control-endpoint', - '--treatment-endpoint', - '--control-online-eval', - '--treatment-online-eval', - '--gateway-filter', - '--control-qualifier', - '--treatment-qualifier', - ]); - for (const opt of abTestCmd.options) { - if (hiddenFromDefaultHelp.has(opt.long ?? '')) { - opt.hidden = true; - } - } - - // Add grouped help text after the default options section - abTestCmd.addHelpText( - 'after', - ` -Config-Bundle Mode (--mode config-bundle) -- default - Split traffic between two config bundle versions. - --runtime Runtime agent to A/B test - --control-bundle Control config bundle name or ARN - --control-version Control config bundle version - --treatment-bundle Treatment config bundle name or ARN - --treatment-version Treatment config bundle version - --online-eval Online evaluation config name or ARN - --traffic-header Header name for traffic routing - -Target-Based Mode (--mode target-based) - Route traffic to different runtime endpoints. - --control-endpoint Endpoint for control target - --treatment-endpoint Endpoint for treatment target - --control-online-eval Eval config name or ARN for control - --treatment-online-eval Eval config name or ARN for treatment - --gateway-filter Path pattern for routing -` - ); - - abTestCmd.action( - async (cliOptions: { - mode?: string; - name?: string; - description?: string; - runtime?: string; - gateway?: string; - roleArn?: string; - controlBundle?: string; - controlVersion?: string; - treatmentBundle?: string; - treatmentVersion?: string; - controlEndpoint?: string; - controlQualifier?: string; // deprecated alias for --control-endpoint - treatmentEndpoint?: string; - treatmentQualifier?: string; // deprecated alias for --treatment-endpoint - controlWeight?: number; - treatmentWeight?: number; - onlineEval?: string; - controlOnlineEval?: string; - treatmentOnlineEval?: string; - gatewayFilter?: string; - trafficHeader?: string; - maxDuration?: number; - enable?: boolean; - json?: boolean; - }) => { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } - - // Resolve deprecated aliases (--control-qualifier -> --control-endpoint, etc.) - const resolvedControlEndpoint = cliOptions.controlEndpoint ?? cliOptions.controlQualifier; - const resolvedTreatmentEndpoint = cliOptions.treatmentEndpoint ?? cliOptions.treatmentQualifier; - - if (cliOptions.name || cliOptions.json) { - const fail = (error: string) => { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error })); - } else { - console.error(error); - } - process.exit(1); - }; - - const mode = cliOptions.mode ?? 'config-bundle'; - if (mode !== 'config-bundle' && mode !== 'target-based') { - fail(`Invalid --mode "${mode}". Must be one of: config-bundle, target-based`); - } - - if (!cliOptions.name) fail('--name is required'); - - // Target-based mode - if (mode === 'target-based') { - // Cross-validation: reject config-bundle flags - if (cliOptions.controlBundle) fail('--control-bundle cannot be used with --mode target-based'); - if (cliOptions.treatmentBundle) fail('--treatment-bundle cannot be used with --mode target-based'); - if (cliOptions.controlVersion) fail('--control-version cannot be used with --mode target-based'); - if (cliOptions.treatmentVersion) fail('--treatment-version cannot be used with --mode target-based'); - if (cliOptions.onlineEval) fail('--online-eval cannot be used with --mode target-based'); - - // Required flags - if (!cliOptions.gateway) fail('--gateway is required for target-based mode'); - if (!cliOptions.runtime) fail('--runtime is required for target-based mode'); - if (!resolvedControlEndpoint) fail('--control-endpoint is required for target-based mode'); - if (!resolvedTreatmentEndpoint) fail('--treatment-endpoint is required for target-based mode'); - if (cliOptions.controlWeight === undefined) fail('--control-weight is required'); - if (cliOptions.treatmentWeight === undefined) fail('--treatment-weight is required'); - - // Eval: require both online eval config names - if (!cliOptions.controlOnlineEval || !cliOptions.treatmentOnlineEval) { - fail( - '--control-online-eval and --treatment-online-eval are required. Create eval configs first with: agentcore add online-eval --endpoint ' - ); - } - - const result = await this.addTargetBased({ - name: cliOptions.name!, - description: cliOptions.description, - gateway: cliOptions.gateway!, - runtime: cliOptions.runtime!, - roleArn: cliOptions.roleArn, - controlEndpoint: resolvedControlEndpoint!, - treatmentEndpoint: resolvedTreatmentEndpoint!, - controlWeight: cliOptions.controlWeight!, - treatmentWeight: cliOptions.treatmentWeight!, - controlOnlineEval: cliOptions.controlOnlineEval!, - treatmentOnlineEval: cliOptions.treatmentOnlineEval!, - gatewayFilter: cliOptions.gatewayFilter, - enableOnCreate: cliOptions.enable, - }); - - if (cliOptions.json) { - console.log(JSON.stringify(serializeResult(result))); - } else if (result.success) { - console.log(`Added target-based AB test '${result.abTestName}'`); - } else { - console.error(result.error.message); - } - process.exit(result.success ? 0 : 1); - return; - } - - // Config-bundle mode (default) - // Cross-validation: reject target-based flags - if (cliOptions.gatewayFilter) fail('--gateway-filter requires --mode target-based'); - if (cliOptions.controlOnlineEval) fail('--control-online-eval requires --mode target-based'); - if (cliOptions.treatmentOnlineEval) fail('--treatment-online-eval requires --mode target-based'); - - if (!cliOptions.gateway && !cliOptions.runtime) - fail('--runtime is required (unless --gateway is provided)'); - if (!cliOptions.controlBundle) fail('--control-bundle is required'); - if (!cliOptions.controlVersion) fail('--control-version is required'); - if (!cliOptions.treatmentBundle) fail('--treatment-bundle is required'); - if (!cliOptions.treatmentVersion) fail('--treatment-version is required'); - if (cliOptions.controlWeight === undefined) fail('--control-weight is required'); - if (cliOptions.treatmentWeight === undefined) fail('--treatment-weight is required'); - if (!cliOptions.onlineEval) fail('--online-eval is required'); - - const result = await this.add({ - name: cliOptions.name!, - description: cliOptions.description, - agent: cliOptions.runtime ?? '', - gatewayChoice: cliOptions.gateway - ? { type: 'existing-http', name: cliOptions.gateway } - : { type: 'create-new' }, - roleArn: cliOptions.roleArn!, - controlBundle: cliOptions.controlBundle!, - controlVersion: cliOptions.controlVersion!, - treatmentBundle: cliOptions.treatmentBundle!, - treatmentVersion: cliOptions.treatmentVersion!, - controlWeight: cliOptions.controlWeight!, - treatmentWeight: cliOptions.treatmentWeight!, - onlineEval: cliOptions.onlineEval!, - trafficHeaderName: cliOptions.trafficHeader, - maxDurationDays: cliOptions.maxDuration, - enableOnCreate: cliOptions.enable, - }); - - if (cliOptions.json) { - console.log(JSON.stringify(serializeResult(result))); - } else if (result.success) { - console.log(`Added AB test '${result.abTestName}'`); - } else { - console.error(result.error.message); - } - process.exit(result.success ? 0 : 1); - } else { - // TUI fallback - const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ - import('ink'), - import('react'), - import('../tui/screens/add/AddFlow'), - ]); - const { clear, unmount } = render( - React.createElement(AddFlow, { - isInteractive: false, - initialResource: 'ab-test', - onExit: () => { - clear(); - unmount(); - process.exit(0); - }, - }) - ); - } - } catch (error) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); - } else { - console.error(getErrorMessage(error)); - } - process.exit(1); - } - } - ); - - removeCmd - .command(this.kind) - .description(`Remove ${this.article} ${this.label.toLowerCase()} from the project`) - .option('--name ', 'Name of resource to remove [non-interactive]') - .option('-y, --yes', 'Skip confirmation prompt [non-interactive]') - .option('--json', 'Output as JSON [non-interactive]') - .option('--delete-gateway', 'Also remove gateway targets and orphaned gateways (default: false)') - .action(async (cliOptions: { name?: string; yes?: boolean; json?: boolean; deleteGateway?: boolean }) => { - try { - if (!findConfigRoot()) { - console.error('No agentcore project found. Run `agentcore create` first.'); - process.exit(1); - } - - if (cliOptions.name || cliOptions.yes || cliOptions.json) { - if (!cliOptions.name) { - console.log(JSON.stringify({ success: false, error: '--name is required' })); - process.exit(1); - } - - const result = await withCommandRunTelemetry('remove.ab-test', {}, () => - this.remove(cliOptions.name!, { deleteGateway: cliOptions.deleteGateway }) - ); - console.log( - JSON.stringify({ - success: result.success, - resourceType: this.kind, - resourceName: cliOptions.name, - message: result.success ? `Removed ${this.label.toLowerCase()} '${cliOptions.name}'` : undefined, - error: !result.success ? result.error.message : undefined, - }) - ); - process.exit(result.success ? 0 : 1); - } else { - // TUI fallback - requireTTY(); - const [{ render }, { default: React }, { RemoveFlow }] = await Promise.all([ - import('ink'), - import('react'), - import('../tui/screens/remove'), - ]); - const { clear, unmount } = render( - React.createElement(RemoveFlow, { - isInteractive: false, - force: cliOptions.yes, - initialResourceType: this.kind, - initialResourceName: cliOptions.name, - onExit: () => { - clear(); - unmount(); - process.exit(0); - }, - }) - ); - } - } catch (error) { - if (cliOptions.json) { - console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); - } else { - console.error(`Error: ${getErrorMessage(error)}`); - } - process.exit(1); - } - }); - } - - addScreen(): AddScreenComponent { - return null; - } - - private async createABTest(options: AddABTestOptions): Promise { - const project = await this.readProjectSpec(); - - this.checkDuplicate(project.abTests ?? [], options.name); - - // Resolve gateway reference based on the user's choice - let gatewayRef: string; - const choice = options.gatewayChoice ?? { type: 'create-new' }; - - if (choice.type === 'existing-http') { - // Reuse an existing HTTP gateway from the project spec - const existing = (project.httpGateways ?? []).find(gw => gw.name === choice.name); - if (!existing) { - throw new Error(`HTTP gateway "${choice.name}" not found in project.`); - } - gatewayRef = `{{gateway:${choice.name}}}`; - } else { - // Create new HTTP gateway — truncate name to fit 48-char limit - const httpGwName = `${options.name.replace(/_/g, '-').slice(0, 44)}-gw`; - const existingGw = (project.httpGateways ?? []).find(gw => gw.name === httpGwName); - if (existingGw) { - if (existingGw.runtimeRef !== options.agent) { - throw new Error( - `HTTP gateway "${httpGwName}" already exists with a different runtime (${existingGw.runtimeRef}). ` + - `Choose a different AB test name to avoid a gateway name collision.` - ); - } - } else { - project.httpGateways ??= []; - project.httpGateways.push({ - name: httpGwName, - runtimeRef: options.agent, - }); - } - gatewayRef = `{{gateway:${httpGwName}}}`; - } - - const abTest: ABTest = { - name: options.name, - mode: 'config-bundle', - ...(options.description && { description: options.description }), - gatewayRef, - ...(options.roleArn && { roleArn: options.roleArn }), - variants: [ - { - name: 'C', - weight: options.controlWeight, - variantConfiguration: { - configurationBundle: { - bundleArn: options.controlBundle, - bundleVersion: options.controlVersion, - }, - }, - }, - { - name: 'T1', - weight: options.treatmentWeight, - variantConfiguration: { - configurationBundle: { - bundleArn: options.treatmentBundle, - bundleVersion: options.treatmentVersion, - }, - }, - }, - ], - evaluationConfig: { - onlineEvaluationConfigArn: options.onlineEval, - }, - ...(options.trafficHeaderName && { - trafficAllocationConfig: { routeOnHeader: { headerName: options.trafficHeaderName } }, - }), - ...(options.maxDurationDays !== undefined && { maxDurationDays: options.maxDurationDays }), - ...(options.enableOnCreate !== undefined && { enableOnCreate: options.enableOnCreate }), - }; - - project.abTests ??= []; - project.abTests.push(abTest); - await this.writeProjectSpec(project); - - return abTest; - } - - async addTargetBased(options: AddTargetBasedABTestOptions): Promise> { - try { - const abTest = await this.createTargetBasedABTest(options); - return { success: true, abTestName: abTest.name }; - } catch (err) { - return { success: false, error: toError(err) }; - } - } - - private async createTargetBasedABTest(options: AddTargetBasedABTestOptions): Promise { - const project = await this.readProjectSpec(); - - this.checkDuplicate(project.abTests ?? [], options.name); - - // Validate runtime exists - const runtime = project.runtimes.find(r => r.name === options.runtime); - if (!runtime) { - throw new Error(`Runtime "${options.runtime}" not found in project.`); - } - - // Validate endpoints exist on the runtime - if (!runtime.endpoints?.[options.controlEndpoint]) { - throw new Error( - `Endpoint "${options.controlEndpoint}" not found on runtime "${options.runtime}". Add it with: agentcore add runtime-endpoint` - ); - } - if (!runtime.endpoints?.[options.treatmentEndpoint]) { - throw new Error( - `Endpoint "${options.treatmentEndpoint}" not found on runtime "${options.runtime}". Add it with: agentcore add runtime-endpoint` - ); - } - - // Auto-generate target names from runtime + qualifier - const controlTarget = `${options.runtime}-${options.controlEndpoint}`; - const treatmentTarget = `${options.runtime}-${options.treatmentEndpoint}`; - - // Auto-create HTTP gateway if it doesn't exist - let existing = (project.httpGateways ?? []).find(gw => gw.name === options.gateway); - if (!existing) { - existing = { - name: options.gateway, - description: `HTTP gateway for AB test ${options.name}`, - runtimeRef: options.runtime, - targets: [ - { name: controlTarget, runtimeRef: options.runtime, qualifier: options.controlEndpoint }, - { name: treatmentTarget, runtimeRef: options.runtime, qualifier: options.treatmentEndpoint }, - ], - }; - project.httpGateways ??= []; - project.httpGateways.push(existing); - } else { - // Gateway exists — ensure targets exist - existing.targets ??= []; - if (!existing.targets.find(t => t.name === controlTarget)) { - existing.targets.push({ - name: controlTarget, - runtimeRef: options.runtime, - qualifier: options.controlEndpoint, - }); - } - if (!existing.targets.find(t => t.name === treatmentTarget)) { - existing.targets.push({ - name: treatmentTarget, - runtimeRef: options.runtime, - qualifier: options.treatmentEndpoint, - }); - } - } - const gatewayRef = `{{gateway:${options.gateway}}}`; - - // Look up online eval configs by name - const controlEvalConfig = project.onlineEvalConfigs.find(c => c.name === options.controlOnlineEval); - if (!controlEvalConfig) { - throw new Error( - `Online eval config '${options.controlOnlineEval}' not found. Create it first with: agentcore add online-eval` - ); - } - const treatmentEvalConfig = project.onlineEvalConfigs.find(c => c.name === options.treatmentOnlineEval); - if (!treatmentEvalConfig) { - throw new Error( - `Online eval config '${options.treatmentOnlineEval}' not found. Create it first with: agentcore add online-eval` - ); - } - - // Store eval names — post-deploy resolveOnlineEvalArn will resolve names to ARNs - const evaluationConfig: ABTest['evaluationConfig'] = { - perVariantOnlineEvaluationConfig: [ - { treatmentName: 'C' as const, onlineEvaluationConfigArn: options.controlOnlineEval }, - { treatmentName: 'T1' as const, onlineEvaluationConfigArn: options.treatmentOnlineEval }, - ], - }; - - const abTest: ABTest = { - name: options.name, - mode: 'target-based', - ...(options.description && { description: options.description }), - gatewayRef, - ...(options.roleArn && { roleArn: options.roleArn }), - variants: [ - { - name: 'C' as const, - weight: options.controlWeight, - variantConfiguration: { - target: { targetName: controlTarget }, - }, - }, - { - name: 'T1' as const, - weight: options.treatmentWeight, - variantConfiguration: { - target: { targetName: treatmentTarget }, - }, - }, - ], - evaluationConfig, - ...(options.gatewayFilter && { - gatewayFilter: { targetPaths: [options.gatewayFilter] }, - }), - ...(options.enableOnCreate !== undefined && { enableOnCreate: options.enableOnCreate }), - }; - - project.abTests ??= []; - project.abTests.push(abTest); - await this.writeProjectSpec(project); - - return abTest; - } -} diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index cc0fea66a..80ffa0fbf 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -322,10 +322,7 @@ export class AgentPrimitive extends BasePrimitive [...prev, val], [] as string[] ) - .option( - '--with-config-bundle', - 'Create a config bundle wired into the agent template [preview] [non-interactive]' - ) + .option('--with-config-bundle', 'Create a config bundle wired into the agent template [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') .action(async options => { if (!findConfigRoot()) { diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index 92c798d8f..64e889447 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -3,9 +3,11 @@ import type { Result } from '../../lib/result'; import type { ConfigBundle } from '../../schema'; import { ConfigBundleSchema } from '../../schema'; import { getErrorMessage } from '../errors'; +import { isGatedFeaturesEnabled } from '../feature-flags'; import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { BasePrimitive } from './BasePrimitive'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import { Option } from '@commander-js/extra-typings'; import type { Command } from '@commander-js/extra-typings'; import { readFileSync } from 'fs'; @@ -106,7 +108,7 @@ export class ConfigBundlePrimitive extends BasePrimitive', 'Bundle name') .option('--description ', 'Bundle description') .option( @@ -114,7 +116,12 @@ export class ConfigBundlePrimitive extends BasePrimitive}}, {{gateway:}}. Placeholders resolve to real ARNs at deploy time.' ) .option('--components-file ', 'Path to components JSON file (same format as --components)') - .option('--branch ', 'Branch name for versioning') + // Gated: custom branches blocked by upstream CFN read-back bug. Remove gate when service fixes GetConfigurationBundle. + .addOption( + isGatedFeaturesEnabled() + ? new Option('--branch ', 'Branch name for versioning') + : new Option('--branch ', 'Branch name for versioning').hideHelp() + ) .option('--commit-message ', 'Commit message for this version') .option('--json', 'Output as JSON') .action( @@ -166,7 +173,7 @@ export class ConfigBundlePrimitive extends BasePrimitive c.evaluators.includes(evaluatorName)); + const referencingConfigs = project.onlineEvalConfigs.filter(c => c.evaluators?.includes(evaluatorName)); if (referencingConfigs.length > 0) { const configNames = referencingConfigs.map(c => c.name).join(', '); return { @@ -116,7 +116,7 @@ export class EvaluatorPrimitive extends BasePrimitive c.evaluators.includes(evaluatorName)); + const referencingConfigs = project.onlineEvalConfigs.filter(c => c.evaluators?.includes(evaluatorName)); if (referencingConfigs.length > 0) { summary.push( `Blocked: Referenced by online eval config(s): ${referencingConfigs.map(c => c.name).join(', ')}. Remove those references first.` diff --git a/src/cli/primitives/GatewayPrimitive.ts b/src/cli/primitives/GatewayPrimitive.ts index d2b93c11c..a17911890 100644 --- a/src/cli/primitives/GatewayPrimitive.ts +++ b/src/cli/primitives/GatewayPrimitive.ts @@ -29,6 +29,7 @@ import type { Command } from '@commander-js/extra-typings'; export interface AddGatewayOptions { name: string; description?: string; + protocolType?: 'MCP' | 'None'; authorizerType: GatewayAuthorizerType; discoveryUrl?: string; allowedAudience?: string; @@ -138,6 +139,52 @@ export class GatewayPrimitive extends BasePrimitive { + try { + const project = await this.readProjectSpec(); + return project.agentCoreGateways.filter(g => g.protocolType !== 'None').map(g => g.name); + } catch { + return []; + } + } + + /** + * Get runtime names from the project spec. + * All runtimes in spec.runtimes are CDK-managed (deployed by the generated CDK stack), + * so no filtering is needed here. + */ + async getRuntimeNames(): Promise { + try { + const project = await this.readProjectSpec(); + return project.runtimes.map(r => r.name); + } catch { + return []; + } + } + + /** + * Get endpoints for a specific runtime from the project spec. + * Returns an array of { name, version } entries from the runtime's endpoints dictionary. + */ + async getRuntimeEndpoints(runtimeName: string): Promise<{ name: string; version: number }[]> { + try { + const project = await this.readProjectSpec(); + const runtime = project.runtimes.find(r => r.name === runtimeName); + if (!runtime?.endpoints) { + return []; + } + return Object.entries(runtime.endpoints).map(([name, ep]) => ({ + name, + version: ep.version, + })); + } catch { + return []; + } + } + /** * Get list of unassigned targets from agentcore.json. */ @@ -164,6 +211,7 @@ export class GatewayPrimitive extends BasePrimitive', 'Gateway name [non-interactive]') .option('--description ', 'Gateway description [non-interactive]') + .option('--protocol-type ', 'Protocol type: MCP or None (default: None) [non-interactive]') .option('--runtimes ', 'Comma-separated runtime names to expose through this gateway [non-interactive]') .option('--authorizer-type ', 'Authorizer type: NONE, AWS_IAM, or CUSTOM_JWT [non-interactive]') .option('--discovery-url ', 'OIDC discovery URL (for CUSTOM_JWT) [non-interactive]') @@ -202,6 +250,7 @@ export class GatewayPrimitive extends BasePrimitive s.trim()) - .filter(Boolean).length - : 0; return { authorizer_type: standardize(AuthorizerType, cliOptions.authorizerType ?? 'NONE'), has_policy_engine: !!cliOptions.policyEngine, policy_engine_mode: standardize(PolicyEngineMode, cliOptions.policyEngineMode ?? 'log_only'), semantic_search: cliOptions.semanticSearch !== false, - runtime_count: runtimeCount, + runtime_count: cliOptions.runtimes + ? cliOptions.runtimes + .split(',') + .map(s => s.trim()) + .filter(Boolean).length + : 0, }; }); }); @@ -317,6 +365,7 @@ export class GatewayPrimitive extends BasePrimitive(option: T): T => (isGatedFeaturesEnabled() ? option : option.hideHelp()); + + const typeDescription = isGatedFeaturesEnabled() + ? 'Target type (required): mcp-server, api-gateway, open-api-schema, smithy-model, lambda-function-arn, http-runtime, connector, passthrough, web-search [non-interactive]' + : 'Target type (required): mcp-server, api-gateway, open-api-schema, smithy-model, lambda-function-arn, http-runtime, connector [non-interactive]'; + + // Reject repeated use of --exclude-domains. Domains must be passed as a + // single comma-separated value. + const excludeDomainsCoercer = (val: string, prev?: string) => { + if (prev !== undefined) { + throw new ValidationError( + '--exclude-domains may only be specified once. Pass all domains as a single comma-separated value.' + ); + } + return val; + }; + addCmd .command('gateway-target') - .description('Add a target (API, MCP server, Lambda) to a gateway for tool routing') + .description('Add a target to a gateway for routing requests to backends') .option('--name ', 'Target name [non-interactive]') .option('--description ', 'Target description [non-interactive]') .option('--gateway ', 'Gateway to attach this target to [non-interactive]') + .option('--type ', typeDescription) .option( - '--type ', - 'Target type (required): mcp-server, api-gateway, open-api-schema, smithy-model, lambda-function-arn [non-interactive]' + '--connector ', + 'Connector id (for connector type): bedrock-knowledge-bases or bedrock-agentic-retrieve [non-interactive]' ) - .option('--endpoint ', 'Server endpoint URL (for mcp-server type) [non-interactive]') + .option( + '--knowledge-base-id ', + 'KB reference for connector type — either a project KB name (entry in knowledgeBases[]) or a 10-char Bedrock KB id for an external KB. Repeatable for --connector bedrock-agentic-retrieve to fan out across multiple KBs. [non-interactive]', + (val: string, acc: string[]) => [...acc, val], + [] as string[] + ) + .addOption( + gate( + new Option( + '--exclude-domains ', + 'Comma-separated domains to exclude from results (for --type web-search only) [non-interactive]' + ).argParser(excludeDomainsCoercer) + ) + ) + .option('--endpoint ', 'Server endpoint URL (for mcp-server type) [non-interactive]') .option('--language ', 'Language of target code: Python, TypeScript, Other [non-interactive]') .option('--host ', 'Where to run the target: Lambda or AgentCoreRuntime [non-interactive]') .option('--outbound-auth ', 'Outbound auth type: oauth, api-key, or none [non-interactive]') @@ -308,8 +361,97 @@ export class GatewayTargetPrimitive extends BasePrimitive', 'Tool schema JSON file path (for lambda-function-arn type) [non-interactive]' ) + .option('--runtime ', 'Runtime from your project (for http-runtime type) [non-interactive]') + .option('--runtime-endpoint ', 'Runtime endpoint / version alias (for http-runtime type) [non-interactive]') + // Passthrough-only flags are gated behind ENABLE_GATED_FEATURES — hidden from help when off. + .addOption( + gatePassthroughOption( + new Option('--passthrough-endpoint ', 'HTTPS endpoint URL for passthrough targets [non-interactive]') + ) + ) + .addOption( + gatePassthroughOption( + new Option( + '--passthrough-protocol ', + 'Passthrough protocol: MCP | A2A | INFERENCE | CUSTOM (default: CUSTOM) [non-interactive]' + ) + ) + ) + .addOption( + gatePassthroughOption( + new Option( + '--stickiness-identifier ', + 'Session routing expression for passthrough targets [non-interactive]' + ) + ) + ) + .addOption( + gatePassthroughOption( + new Option('--stickiness-timeout ', 'Sticky session timeout in seconds (1-86400) [non-interactive]') + ) + ) + .addOption( + gatePassthroughOption( + new Option( + '--signing-service ', + 'SigV4 signing service name for passthrough GATEWAY_IAM_ROLE auth [non-interactive]' + ) + ) + ) + .addOption( + gatePassthroughOption( + new Option( + '--signing-region ', + 'SigV4 signing region for passthrough (defaults to project region) [non-interactive]' + ) + ) + ) .option('--json', 'Output as JSON [non-interactive]') - .action(async (rawOptions: Record) => { + .addHelpText( + 'after', + ` +Target types and their options: + + http-runtime — Route to an AgentCore runtime + --runtime Runtime from your project + --runtime-endpoint Endpoint / version alias (optional) + + mcp-server — Connect to an MCP-compatible server + --endpoint Server endpoint URL + --host Lambda or AgentCoreRuntime + --language Python, TypeScript, or Other + + api-gateway — Connect to an Amazon API Gateway REST API + --rest-api-id REST API ID + --stage Deployment stage + + open-api-schema / smithy-model — Auto-derive tools from a schema + --schema Schema file path or S3 URI + --schema-s3-account S3 bucket owner account ID + + lambda-function-arn — Connect to an AWS Lambda function + --lambda-arn Lambda function ARN + --tool-schema-file Tool schema JSON file + + connector — Wire a managed AWS connector (Bedrock KB, agentic-retrieve) + --connector bedrock-knowledge-bases or bedrock-agentic-retrieve + --knowledge-base-id Project KB name or 10-char external KB id (repeatable for agentic-retrieve) +${ + isGatedFeaturesEnabled() + ? ` + passthrough — Route to an external HTTPS endpoint + --passthrough-endpoint HTTPS endpoint URL + --stickiness-identifier Session routing expression (optional) + --stickiness-timeout Sticky session timeout in seconds (optional) +` + : '' +} + Auth (mcp-server, open-api-schema, smithy-model, lambda-function-arn${isGatedFeaturesEnabled() ? ', passthrough' : ''}): + --outbound-auth oauth, api-key, or none + --credential-name Existing credential name +` + ) + .action(async (rawOptions: Record) => { // Commander camelCases --outbound-auth to outboundAuth, but our types use outboundAuthType if (rawOptions.outboundAuth && !rawOptions.outboundAuthType) { rawOptions.outboundAuthType = rawOptions.outboundAuth; @@ -328,12 +470,17 @@ export class GatewayTargetPrimitive extends BasePrimitive = { - oauth: 'OAUTH', - 'api-key': 'API_KEY', - api_key: 'API_KEY', - none: 'NONE', - }; + const outboundAuthMap: Record = + { + oauth: 'OAUTH', + 'api-key': 'API_KEY', + api_key: 'API_KEY', + none: 'NONE', + gateway_iam_role: 'GATEWAY_IAM_ROLE', + 'gateway-iam-role': 'GATEWAY_IAM_ROLE', + jwt_passthrough: 'JWT_PASSTHROUGH', + 'jwt-passthrough': 'JWT_PASSTHROUGH', + }; const cliType = cliOptions.type ?? ''; const telemetryTargetType = GATEWAY_TARGET_TYPE_MAP[cliType] ?? ('unknown' as const); @@ -407,7 +554,10 @@ export class GatewayTargetPrimitive extends BasePrimitive s.trim()), + } + : undefined, + }); + const output = { success: true, toolName: result.toolName }; + if (cliOptions.json) { + console.log(JSON.stringify(output)); + } else { + console.log(`Added gateway target '${result.toolName}'`); + } + return telemetryAttrs; + } + + // Handle Amazon Web Search targets (managed-service backed via gateway IAM role) + if (cliOptions.type === 'webSearch') { + if (!isGatedFeaturesEnabled()) { + throw new ValidationError('Web search target type is not yet available.'); + } + const excludeDomains = + typeof cliOptions.excludeDomains === 'string' + ? cliOptions.excludeDomains + .split(',') + .map((d: string) => d.trim()) + .filter((d: string) => d.length > 0) + : undefined; + const config: WebSearchTargetConfig = { + targetType: 'webSearch', + name: cliOptions.name!, + gateway: cliOptions.gateway!, + ...(excludeDomains && excludeDomains.length > 0 ? { excludeDomains } : {}), + }; + const result = await this.createWebSearchGatewayTarget(config); + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, toolName: result.toolName })); + } else { + const suffix = config.excludeDomains ? ` (excludeDomains=${config.excludeDomains.join(',')})` : ''; + console.log(`Added web-search gateway target '${result.toolName}' on '${config.gateway}'${suffix}`); + } + return telemetryAttrs; + } + + // Handle connector targets (managed-service backed: KB single-retrieve, agentic-retrieve fan-out) + if (cliOptions.type === 'connector') { + const validConnectors = CONNECTOR_ID_VALUES.join(', '); + if (!cliOptions.connector) { + throw new ValidationError(`--connector is required for connector targets (${validConnectors}).`); + } + if (!(CONNECTOR_ID_VALUES as readonly string[]).includes(cliOptions.connector)) { + throw new ValidationError( + `Unknown --connector value '${cliOptions.connector}'. Valid: ${validConnectors}.` + ); + } + const connectorId = cliOptions.connector as ConnectorId; + const kbRefs = cliOptions.knowledgeBaseId ?? []; + if (kbRefs.length === 0) { + throw new ValidationError(`--knowledge-base-id is required for --connector ${connectorId}.`); + } + + let config: ConnectorTargetConfig; + if (connectorId === 'bedrock-knowledge-bases') { + if (kbRefs.length > 1) { + throw new ValidationError( + '--knowledge-base-id may only be specified once for --connector bedrock-knowledge-bases. ' + + 'Use --connector bedrock-agentic-retrieve for fan-out across multiple KBs.' + ); + } + config = { + targetType: 'connector', + name: cliOptions.name!, + gateway: cliOptions.gateway!, + connectorId, + knowledgeBaseId: kbRefs[0]!, + ...(cliOptions.description && { description: cliOptions.description }), + }; + } else { + // bedrock-agentic-retrieve: fan-out via knowledgeBaseIds[]. + config = { + targetType: 'connector', + name: cliOptions.name!, + gateway: cliOptions.gateway!, + connectorId, + knowledgeBaseIds: kbRefs, + ...(cliOptions.description && { description: cliOptions.description }), + }; + } + const result = await this.createConnectorGatewayTarget(config); + const output = { success: true, toolName: result.toolName }; + if (cliOptions.json) { + console.log(JSON.stringify(output)); + } else if (config.connectorId === 'bedrock-agentic-retrieve') { + console.log( + `Added connector gateway target '${result.toolName}' on '${config.gateway}' → ${config.connectorId} (KBs ${kbRefs.join(', ')})` + ); + } else { + console.log( + `Added connector gateway target '${result.toolName}' on '${config.gateway}' → ${config.connectorId} (KB ${kbRefs[0]})` + ); + console.log( + `Also wired KB '${kbRefs[0]}' into gateway '${config.gateway}'-agentic (bedrock-agentic-retrieve fan-out)` + ); + } + return telemetryAttrs; + } + + // Handle passthrough targets (no code generation) + if (cliOptions.type === 'passthrough') { + const passthroughEndpoint = (cliOptions as Record).passthroughEndpoint; + if (!passthroughEndpoint) { + throw new ValidationError('--passthrough-endpoint is required for passthrough type'); + } + const stickinessIdentifier = (cliOptions as Record).stickinessIdentifier; + const stickinessTimeoutRaw = (cliOptions as Record).stickinessTimeout; + const stickinessTimeout = stickinessTimeoutRaw ? parseInt(stickinessTimeoutRaw, 10) : undefined; + const signingService = (rawOptions as Record).signingService; + const signingRegion = (rawOptions as Record).signingRegion; + const protocolTypeRaw = (rawOptions as Record).passthroughProtocol; + const protocolType = protocolTypeRaw?.toUpperCase() ?? 'CUSTOM'; + if (!PASSTHROUGH_PROTOCOL_TYPES.includes(protocolType as PassthroughProtocolType)) { + throw new ValidationError( + `Invalid --passthrough-protocol "${protocolTypeRaw}". Must be one of: ${PASSTHROUGH_PROTOCOL_TYPES.join(', ')}` + ); + } + + // Build outboundAuth based on the auth type + let passthroughOutboundAuth: + | { type: string; credentialName?: string; scopes?: string[]; service?: string; region?: string } + | undefined; + if (cliOptions.outboundAuthType) { + const mappedAuthType = outboundAuthMap[cliOptions.outboundAuthType.toLowerCase()] ?? 'NONE'; + if (mappedAuthType === 'GATEWAY_IAM_ROLE') { + if (!signingService) { + throw new ValidationError( + '--signing-service is required when --outbound-auth is GATEWAY_IAM_ROLE for passthrough targets' + ); + } + passthroughOutboundAuth = { + type: 'GATEWAY_IAM_ROLE', + service: signingService, + ...(signingRegion && { region: signingRegion }), + }; + } else if (mappedAuthType === 'JWT_PASSTHROUGH') { + passthroughOutboundAuth = { type: 'JWT_PASSTHROUGH' }; + } else if (mappedAuthType === 'OAUTH') { + passthroughOutboundAuth = { + type: 'OAUTH', + credentialName: cliOptions.credentialName, + scopes: cliOptions.oauthScopes?.split(',').map(s => s.trim()), + }; + } else if (mappedAuthType !== 'NONE') { + passthroughOutboundAuth = { + type: mappedAuthType, + credentialName: cliOptions.credentialName, + scopes: cliOptions.oauthScopes?.split(',').map(s => s.trim()), + }; + } + } + + const result = await this.createPassthroughTarget({ + name: cliOptions.name!, + gateway: cliOptions.gateway!, + passthroughEndpoint, + protocolType: protocolType as PassthroughProtocolType, + stickinessIdentifier, + stickinessTimeout, + outboundAuth: passthroughOutboundAuth, + }); + const output = { success: true, toolName: result.toolName }; + if (cliOptions.json) { + console.log(JSON.stringify(output)); + } else { + console.log(`Added gateway target '${result.toolName}'`); + } + return telemetryAttrs; + } + // Handle MCP server targets (existing endpoint, no code generation) if (cliOptions.type === 'mcpServer' && cliOptions.endpoint) { const config: McpServerTargetConfig = { @@ -458,7 +795,10 @@ export class GatewayTargetPrimitive extends BasePrimitive', 'Target name (default: web-search) [non-interactive]') + .option('--gateway ', 'Gateway to attach this target to [non-interactive]') + .option('--exclude-domains ', 'Comma-separated domains to exclude from results [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .action(async (cliOptions: { name?: string; gateway?: string; excludeDomains?: string; json?: boolean }) => { + if (!isGatedFeaturesEnabled()) { + console.error('Error: Web search target type is not yet available.'); + process.exit(1); + } + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + const userPassedAnyFlag = + !!cliOptions.name || !!cliOptions.gateway || !!cliOptions.excludeDomains || !!cliOptions.json; + if (!userPassedAnyFlag) { + try { + requireTTY(); + const [{ render }, { default: React }, { AddWebSearchFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/web-search'), + ]); + const { clear, unmount } = render( + React.createElement(AddWebSearchFlow, { + isInteractive: false, + onBack: () => { + clear(); + unmount(); + process.exit(0); + }, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + return; + } catch (error) { + console.error(getErrorMessage(error)); + process.exit(1); + } + } + + await runCliCommand('add.web-search', !!cliOptions.json, async () => { + // Default name `web-search` is convenient for the first invocation + // but produces a duplicate-target error on the second. Require an + // explicit --name when the default is already taken. + let resolvedName = cliOptions.name; + if (!resolvedName) { + const project = await this.readProjectSpec(); + const nameTaken = project.agentCoreGateways.some(g => (g.targets ?? []).some(t => t.name === 'web-search')); + if (nameTaken) { + throw new ValidationError( + 'A gateway target named "web-search" already exists. Pass --name to add another.' + ); + } + resolvedName = 'web-search'; + } + const forwardedOptions: CLIAddGatewayTargetOptions = { + name: resolvedName, + type: 'web-search', + gateway: cliOptions.gateway, + ...(cliOptions.excludeDomains && { excludeDomains: cliOptions.excludeDomains }), + }; + const validation = await validateAddGatewayTargetOptions(forwardedOptions); + if (!validation.valid) { + throw new ValidationError(validation.error!); + } + const excludeDomains = + typeof forwardedOptions.excludeDomains === 'string' + ? forwardedOptions.excludeDomains + .split(',') + .map((d: string) => d.trim()) + .filter((d: string) => d.length > 0) + : undefined; + const config: WebSearchTargetConfig = { + targetType: 'webSearch', + name: forwardedOptions.name!, + gateway: forwardedOptions.gateway!, + ...(excludeDomains && excludeDomains.length > 0 ? { excludeDomains } : {}), + }; + const result = await this.createWebSearchGatewayTarget(config); + if (cliOptions.json) { + console.log(JSON.stringify({ success: true, toolName: result.toolName })); + } else { + const suffix = config.excludeDomains ? ` (excludeDomains=${config.excludeDomains.join(',')})` : ''; + console.log(`Added web-search gateway target '${result.toolName}' on '${config.gateway}'${suffix}`); + } + return {}; + }); + }); + + removeCmd + .command('web-search', { hidden: !isGatedFeaturesEnabled() }) + .description('Remove an Amazon Web Search gateway target from the project') + .option('--name ', 'Name of the web-search target to remove [non-interactive]') + .option('-y, --yes', 'Skip confirmation prompt [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .action(async (cliOptions: { name?: string; yes?: boolean; json?: boolean }) => { + try { + if (!isGatedFeaturesEnabled()) { + console.error('Web search target type is not yet available.'); + process.exit(1); + } + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (!cliOptions.name) { + throw new ValidationError('A --name is required for `agentcore remove web-search`.'); + } + const project = await this.readProjectSpec(); + const match = project.agentCoreGateways + .flatMap(g => (g.targets ?? []).map(t => ({ gateway: g.name, target: t }))) + .find(({ target }) => target.name === cliOptions.name); + if (!match) { + throw new ValidationError(`Gateway target "${cliOptions.name}" not found.`); + } + if (match.target.targetType !== 'webSearch') { + throw new ValidationError( + `Gateway target "${cliOptions.name}" is type "${match.target.targetType}", not webSearch. Use 'agentcore remove gateway-target --name ${cliOptions.name}' instead.` + ); + } + const result = await withCommandRunTelemetry('remove.web-search', {}, () => this.remove(cliOptions.name!)); + if (cliOptions.json) { + console.log( + JSON.stringify({ + success: result.success, + resourceType: this.kind, + resourceName: cliOptions.name, + message: result.success ? `Removed web-search gateway target '${cliOptions.name}'` : undefined, + error: !result.success ? result.error.message : undefined, + }) + ); + } else if (result.success) { + console.log(`Removed web-search gateway target '${cliOptions.name}'`); + } else { + throw result.error; + } + process.exit(result.success ? 0 : 1); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + }); } addScreen(): AddScreenComponent { @@ -713,6 +1213,231 @@ export class GatewayTargetPrimitive extends BasePrimitive { + const project = await this.readProjectSpec(); + + const gateway = project.agentCoreGateways.find(g => g.name === config.gateway); + if (!gateway) { + throw new Error(`Gateway "${config.gateway}" not found.`); + } + + if (!gateway.targets) { + gateway.targets = []; + } + + if (gateway.targets.some(t => t.name === config.name)) { + throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`); + } + + const target: AgentCoreGatewayTarget = { + name: config.name, + targetType: 'httpRuntime', + httpRuntime: { + runtime: config.runtime, + ...(config.endpoint && { runtimeEndpoint: config.endpoint }), + }, + ...(config.outboundAuth && + config.outboundAuth.type !== 'NONE' && { + outboundAuth: { + type: config.outboundAuth.type as 'OAUTH' | 'API_KEY', + credentialName: config.outboundAuth.credentialName!, + ...(config.outboundAuth.scopes && { scopes: config.outboundAuth.scopes }), + }, + }), + }; + + gateway.targets.push(target); + await this.writeProjectSpec(project); + + return { toolName: config.name }; + } + + /** + * Create a connector-typed gateway target backed by a managed AWS service + * (currently bedrock-knowledge-bases or bedrock-agentic-retrieve). + * + * Project-owned KB: config.knowledgeBaseId is a knowledgeBases[] entry name; + * the L3 resolves it at synth time via application.knowledgeBases. + * External KB: config.knowledgeBaseId is a 10-character literal KB ID; the + * L3 passes it through verbatim. + */ + async createConnectorGatewayTarget(config: ConnectorTargetConfig): Promise<{ toolName: string }> { + const project = await this.readProjectSpec(); + + const gateway = project.agentCoreGateways.find(g => g.name === config.gateway); + if (!gateway) { + throw new Error(`Gateway "${config.gateway}" not found.`); + } + + if (!gateway.targets) { + gateway.targets = []; + } + + if (gateway.targets.some(t => t.name === config.name)) { + throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`); + } + + // For agentic-retrieve, refuse to silently shadow an existing one on the + // same gateway — the KB primitive would have created `${gateway}-agentic` + // already, and a user-driven low-level add should be an explicit choice. + if (config.connectorId === 'bedrock-agentic-retrieve') { + const existingAgentic = gateway.targets.find( + t => t.targetType === 'connector' && t.connectorId === 'bedrock-agentic-retrieve' + ); + if (existingAgentic) { + throw new Error( + `Gateway "${gateway.name}" already has a bedrock-agentic-retrieve target ("${existingAgentic.name}"). ` + + `Edit agentcore/agentcore.json directly to extend its knowledgeBaseIds[].` + ); + } + } + + let target: AgentCoreGatewayTarget; + if (config.connectorId === 'bedrock-agentic-retrieve') { + target = { + name: config.name, + targetType: 'connector', + connectorId: config.connectorId, + knowledgeBaseIds: config.knowledgeBaseIds, + } as AgentCoreGatewayTarget; + } else { + target = { + name: config.name, + targetType: 'connector', + connectorId: config.connectorId, + knowledgeBaseId: config.knowledgeBaseId, + } as AgentCoreGatewayTarget; + } + + gateway.targets.push(target); + + // Auto-upsert the shared agentic-retrieve target when wiring a single-KB + // Retrieve via this path, mirroring KnowledgeBasePrimitive.add({...gateway}). + // Without this, KBs added via `add gateway-target --type connector + // --connector bedrock-knowledge-bases` would be missing from the gateway's + // agentic-retrieve fan-out. + if (config.connectorId === 'bedrock-knowledge-bases') { + upsertAgenticRetrieveTarget(gateway, config.knowledgeBaseId); + } + + await this.writeProjectSpec(project); + + return { toolName: config.name }; + } + + /** + * Create a passthrough target that routes HTTP traffic to an external HTTPS endpoint. + * No code generation — this registers an endpoint for HTTP passthrough. + */ + async createPassthroughTarget(config: { + name: string; + gateway: string; + passthroughEndpoint: string; + protocolType?: PassthroughProtocolType; + stickinessIdentifier?: string; + stickinessTimeout?: number; + outboundAuth?: { type: string; credentialName?: string; scopes?: string[]; service?: string; region?: string }; + }): Promise<{ toolName: string }> { + const project = await this.readProjectSpec(); + + const gateway = project.agentCoreGateways.find(g => g.name === config.gateway); + if (!gateway) { + throw new Error(`Gateway "${config.gateway}" not found.`); + } + + if (!gateway.targets) { + gateway.targets = []; + } + + if (gateway.targets.some(t => t.name === config.name)) { + throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`); + } + + // Build outboundAuth object based on auth type + let outboundAuth: AgentCoreGatewayTarget['outboundAuth']; + if (config.outboundAuth && config.outboundAuth.type !== 'NONE') { + if (config.outboundAuth.type === 'GATEWAY_IAM_ROLE') { + outboundAuth = { + type: 'GATEWAY_IAM_ROLE', + service: config.outboundAuth.service, + ...(config.outboundAuth.region && { region: config.outboundAuth.region }), + }; + } else if (config.outboundAuth.type === 'JWT_PASSTHROUGH') { + outboundAuth = { type: 'JWT_PASSTHROUGH' }; + } else { + outboundAuth = { + type: config.outboundAuth.type as 'OAUTH' | 'API_KEY', + credentialName: config.outboundAuth.credentialName!, + ...(config.outboundAuth.scopes && { scopes: config.outboundAuth.scopes }), + }; + } + } + + const target: AgentCoreGatewayTarget = { + name: config.name, + targetType: 'passthrough', + passthrough: { + endpoint: config.passthroughEndpoint, + protocolType: config.protocolType ?? 'CUSTOM', + ...(config.stickinessIdentifier && { + stickinessConfiguration: { + identifier: config.stickinessIdentifier, + ...(config.stickinessTimeout && { timeout: config.stickinessTimeout }), + }, + }), + }, + ...(outboundAuth && { outboundAuth }), + }; + + gateway.targets.push(target); + await this.writeProjectSpec(project); + + return { toolName: config.name }; + } + + /** + * Create an Amazon Web Search gateway target. The target is invoked via the + * gateway's IAM role; the only admin-configurable parameter is an optional + * list of domains to exclude from results. + */ + async createWebSearchGatewayTarget(config: WebSearchTargetConfig): Promise<{ toolName: string }> { + const project = await this.readProjectSpec(); + + const gateway = project.agentCoreGateways.find(g => g.name === config.gateway); + if (!gateway) { + throw new Error(`Gateway "${config.gateway}" not found.`); + } + + if (!gateway.targets) { + gateway.targets = []; + } + + if (gateway.targets.some(t => t.name === config.name)) { + throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`); + } + + const target: AgentCoreGatewayTarget = { + name: config.name, + targetType: 'webSearch', + ...(config.excludeDomains && config.excludeDomains.length > 0 ? { excludeDomains: config.excludeDomains } : {}), + } as AgentCoreGatewayTarget; + + gateway.targets.push(target); + await this.writeProjectSpec(project); + + return { toolName: config.name }; + } + // ═══════════════════════════════════════════════════════════════════ // Private helpers // ═══════════════════════════════════════════════════════════════════ diff --git a/src/cli/primitives/HarnessPrimitive.ts b/src/cli/primitives/HarnessPrimitive.ts index cf5acd083..8bbc2dceb 100644 --- a/src/cli/primitives/HarnessPrimitive.ts +++ b/src/cli/primitives/HarnessPrimitive.ts @@ -1,43 +1,154 @@ import { APP_DIR, ConfigIO, type Result, findConfigRoot } from '../../lib'; import type { + AgentCoreProjectSpec, + EndpointIpAddressType, HarnessApiFormat, HarnessGatewayOutboundAuth, + HarnessMemoryRef, HarnessModelProvider, HarnessSpec, + ManagedMemoryStrategy, MemoryStrategy, MemoryStrategyType, NetworkMode, + PrivateEndpoint, RuntimeAuthorizerType, } from '../../schema'; import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, DEFAULT_STRATEGY_NAMESPACES, HarnessSpecSchema } from '../../schema'; -import { deleteHarness } from '../aws/agentcore-harness'; +import { deleteHarness, isHarnessNotFoundError } from '../aws/agentcore-harness'; import { getErrorMessage } from '../errors'; +import { isGatedFeaturesEnabled } from '../feature-flags'; +import { MANAGED_MEMORY_ADD_NOTICE } from '../operations/deploy'; +import { findOrphanHarnesses } from '../operations/harness/orphan'; +import type { OrphanHarness } from '../operations/harness/orphan'; import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; +import { withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; +import type { SubCommand } from '../telemetry/schemas/command-run.js'; import { getTemplatePath } from '../templates/templateRoot'; +import { requireTTY } from '../tui/guards/tty'; import { DEFAULT_MEMORY_EXPIRY_DAYS } from '../tui/screens/generate/defaults'; import { BasePrimitive } from './BasePrimitive'; import { buildAuthorizerConfigFromJwtConfig, createManagedOAuthCredential } from './auth-utils'; import type { JwtConfigOptions } from './auth-utils'; +import { ADDITIONAL_PARAMS_JSON_ERROR, SOURCE_CODE_NOTE } from './constants'; import type { AddScreenComponent, RemovableResource } from './types'; -import { ResourceNotFoundError, toError } from '@/lib/errors/types'; +import { validateGitSkillCredential } from '@/cli/operations/harness/skill-utils'; +import { ResourceNotFoundError, ValidationError, toError } from '@/lib/errors/types'; +import { InvalidArgumentError, Option } from '@commander-js/extra-typings'; import type { Command } from '@commander-js/extra-typings'; import { access, copyFile, mkdir, readFile, rm, writeFile } from 'fs/promises'; import { basename, dirname, isAbsolute, join, resolve } from 'path'; +/** Commander accumulator for repeatable `--efs/--s3-access-point ` flags. */ +function collectAccessPoint(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +/** + * Strict integer flag parser. Rejects non-canonical input (`5abc`, `abc`, `1e9`, ` 5 `) with + * Commander's InvalidArgumentError instead of silently truncating (`parseInt('5abc')` → 5) or + * producing NaN, both of which would slip a wrong/garbage value into the spec. + */ +function strictInt(label: string): (value: string) => number { + return (value: string) => { + const trimmed = value.trim(); + const n = Number(trimmed); + // Require canonical integer form: rejects "", "5abc", "5.5", "1e9", "007", " 5 " producing a + // surprising value. `String(n) === trimmed` only holds for the plain decimal the user typed. + if (!Number.isInteger(n) || String(n) !== trimmed) { + throw new InvalidArgumentError(`${label} must be an integer, got "${value}"`); + } + return n; + }; +} + +/** Strict finite-number flag parser. Rejects NaN/Infinity/garbage (`abc`, `1e10000`, `5abc`). */ +function strictFloat(label: string): (value: string) => number { + return (value: string) => { + const n = Number(value); + if (!Number.isFinite(n) || value.trim() === '') { + throw new InvalidArgumentError(`${label} must be a number, got "${value}"`); + } + return n; + }; +} + +/** + * Hide a gated option from `--help` when ENABLE_GATED_FEATURES is off. The option still PARSES + * (so explicit use is caught by validation with a clean "not yet available" message) but does not + * advertise itself. Mirrors the AWS Skills gating pattern (skill-command.ts). + */ +function gatedOption(option: T): T { + return isGatedFeaturesEnabled() ? option : option.hideHelp(); +} + +/** Commander accumulator for repeatable `--env`/`--tag` KEY=VALUE flags. Last write wins per key. */ +function collectKeyValue(value: string, previous: Record): Record { + const eq = value.indexOf('='); + if (eq <= 0) { + throw new Error(`Invalid KEY=VALUE pair: "${value}" (expected KEY=VALUE with a non-empty key)`); + } + return { ...previous, [value.slice(0, eq)]: value.slice(eq + 1) }; +} + +/** + * Parse `--efs/--s3-access-point` values of the form `:` into the + * `{accessPointArn, mountPath}` shape. The ARN itself contains colons, and the mount path is a + * `/mnt/...` absolute path, so split on the LAST colon to separate the two unambiguously. + */ +function parseAccessPoints(raw: string[] | undefined): { accessPointArn: string; mountPath: string }[] | undefined { + if (!raw || raw.length === 0) return undefined; + return raw.map(entry => { + const sep = entry.lastIndexOf(':'); + if (sep <= 0 || sep === entry.length - 1) { + throw new Error(`Invalid access point "${entry}" (expected :)`); + } + return { accessPointArn: entry.slice(0, sep), mountPath: entry.slice(sep + 1) }; + }); +} + export interface AddHarnessOptions { name: string; modelProvider: HarnessModelProvider; modelId: string; apiFormat?: HarnessApiFormat; apiKeyArn?: string; + /** LiteLLM only: base URL for the third-party model provider's API endpoint. */ + apiBase?: string; + /** LiteLLM only: provider-specific parameters passed through to the model provider unchanged. */ + additionalParams?: Record; + /** Model-config sampling params (CFN ModelConfig.{Temperature,TopP,TopK,MaxTokens}). */ + temperature?: number; + topP?: number; + topK?: number; + /** Model-config sampling MaxTokens (distinct from the top-level execution maxTokens). */ + modelMaxTokens?: number; systemPrompt?: string; skipMemory?: boolean; + /** Memory mode (gated). managed = harness owns its memory; existing = BYO; disabled = none. */ + memoryMode?: 'managed' | 'existing' | 'disabled'; + /** Managed-memory strategies (gated). Subset of SEMANTIC/SUMMARIZATION/USER_PREFERENCE/EPISODIC. */ + memoryStrategies?: string[]; + /** Managed-memory event retention in days, 3-365 (gated). */ + memoryEventExpiryDays?: number; + /** Managed-memory KMS CMK ARN, create-only (gated). */ + memoryEncryptionKeyArn?: string; + /** Reference an existing memory by name or ARN instead of auto-creating one. */ + memoryName?: string; + memoryArn?: string; + /** Deploy-time ActorId for the referenced memory (CFN Memory.ActorId). */ + memoryActorId?: string; + /** Recent-message window loaded into context (CFN Memory.MessagesCount). */ + messagesCount?: number; + /** Retrieval tuning fanned across the memory's namespaces (CFN RetrievalConfig.{TopK,RelevanceScore}). */ + memoryTopK?: number; + memoryRelevanceScore?: number; containerUri?: string; dockerfilePath?: string; maxIterations?: number; maxTokens?: number; timeoutSeconds?: number; - truncationStrategy?: 'sliding_window' | 'summarization'; + truncationStrategy?: 'sliding_window' | 'summarization' | 'none'; networkMode?: NetworkMode; subnets?: string[]; securityGroups?: string[]; @@ -46,27 +157,60 @@ export interface AddHarnessOptions { sessionStoragePath?: string; efsAccessPoints?: { accessPointArn: string; mountPath: string }[]; s3AccessPoints?: { accessPointArn: string; mountPath: string }[]; + /** Allow-list of tools the agent may use (CFN AllowedTools). */ + allowedTools?: string[]; + /** Harness runtime environment variables (CFN EnvironmentVariables). */ + environmentVariables?: Record; + /** Harness-level tags, merged with project tags (CFN Tags). */ + tags?: Record; withInvokeScript?: boolean; selectedTools?: string[]; mcpName?: string; mcpUrl?: string; + /** remote_mcp request headers (CFN RemoteMcp.Headers). */ + mcpHeaders?: Record; gatewayArn?: string; gatewayOutboundAuth?: 'awsIam' | 'none' | 'oauth'; gatewayProviderArn?: string; gatewayScopes?: string[]; + /** Gateway OAuth grant type + custom parameters (CFN Oauth.{GrantType,CustomParameters}). */ + gatewayGrantType?: 'CLIENT_CREDENTIALS' | 'USER_FEDERATION'; + gatewayCustomParameters?: Record; authorizerType?: RuntimeAuthorizerType; jwtConfig?: JwtConfigOptions; + skills?: { + path?: string; + s3Uri?: string; + gitUrl?: string; + gitPath?: string; + credentialName?: string; + username?: string; + awsSkills?: string[]; + }[]; configBaseDir?: string; } export type RemovableHarness = RemovableResource; +/** + * Intent for removing an imperative-build orphan harness (one not managed by CloudFormation). + * - `keep`: delete the AWS resource but keep the agentcore.json entry (it moves to GA — the + * next deploy recreates it under CloudFormation). + * - `discard`: delete the AWS resource and remove the agentcore.json entry (no longer wanted). + */ +export type OrphanAction = 'keep' | 'discard'; + +export interface RemoveHarnessOptions { + /** Explicit intent when the named harness is an orphan. Required to delete one (never auto-deletes). */ + orphanAction?: OrphanAction; +} + export class HarnessPrimitive extends BasePrimitive { readonly kind = 'harness' as const; readonly label = 'Harness'; readonly primitiveSchema = HarnessSpecSchema; - async add(options: AddHarnessOptions): Promise> { + async add(options: AddHarnessOptions): Promise> { try { const configBaseDir = options.configBaseDir ?? findConfigRoot(); if (!configBaseDir) { @@ -82,7 +226,17 @@ export class HarnessPrimitive extends BasePrimitive 0 && { headers: options.mcpHeaders }), + }, + }, }); } else if (toolType === 'agentcore_gateway' && options.gatewayArn) { let outboundAuth: HarnessGatewayOutboundAuth | undefined; @@ -131,6 +291,11 @@ export class HarnessPrimitive extends BasePrimitive 0 && { + customParameters: options.gatewayCustomParameters, + }), }, }; } @@ -148,6 +313,29 @@ export class HarnessPrimitive extends BasePrimitive 0 && { paths: s.awsSkills }) } }); + } else { + skills.push({ path: s.path! }); + } + } + const harnessSpec: HarnessSpec = { name: options.name, model: { @@ -155,11 +343,18 @@ export class HarnessPrimitive extends BasePrimitive 0 && { + environmentVariables: options.environmentVariables, + }), + ...(options.tags && Object.keys(options.tags).length > 0 && { tags: options.tags }), ...(options.authorizerType && { authorizerType: options.authorizerType }), ...(options.authorizerType === 'CUSTOM_JWT' && options.jwtConfig ? { authorizerConfiguration: buildAuthorizerConfigFromJwtConfig(options.jwtConfig) } @@ -190,7 +390,10 @@ export class HarnessPrimitive extends BasePrimitive ({ type, @@ -211,7 +414,7 @@ export class HarnessPrimitive extends BasePrimitive { + async remove(harnessName: string, opts?: RemoveHarnessOptions): Promise { try { const configRoot = findConfigRoot(); if (!configRoot) { @@ -251,53 +454,132 @@ export class HarnessPrimitive extends BasePrimitive undefined); - const harnesses = project.harnesses ?? []; - const harnessIndex = harnesses.findIndex(h => h.name === harnessName); + // An orphan is an imperative-build harness recorded in deployed-state but not managed by + // CloudFormation (no `provisioner: 'cloudformation'` marker). CFN can't delete it, so it + // keeps billing and would 409 a same-named CFN deploy. It must be deleted directly from + // AWS — but only with the user's explicit intent (never auto-delete). + const orphans = findOrphanHarnesses(deployedState, harnessName); + if (orphans.length > 0) { + return this.removeOrphan(harnessName, orphans, opts?.orphanAction, configIO, project); + } - if (harnessIndex === -1) { + const inSpec = (project.harnesses ?? []).some(h => h.name === harnessName); + if (!inSpec) { return { success: false, error: new ResourceNotFoundError(`Harness "${harnessName}" not found.`) }; } - // Delete harness from AWS if it's deployed + // --keep/--discard express intent for deleting an imperative-build ORPHAN directly from AWS. + // They have no meaning for a CDK-managed harness (removed by the next deploy via the stack), + // so reject them rather than silently ignoring — the user expected an AWS-side delete. + if (opts?.orphanAction) { + return { + success: false, + error: new ValidationError( + `--keep/--discard only apply to a preview-build (orphan) harness. "${harnessName}" is managed by ` + + `CloudFormation; remove it without those flags and run \`agentcore deploy\` to delete it from AWS.` + ), + }; + } + + // CDK-managed harness: drop it from the project spec. The harness is part of the + // CloudFormation stack, so the next deploy removes the AWS::BedrockAgentCore::Harness. + await this.removeFromSpec(harnessName, configIO, project); + return { success: true }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + /** + * Delete an imperative-build orphan harness directly from AWS, then reconcile local state + * per the user's chosen intent. Never auto-deletes: an unspecified action returns an + * actionable error rather than guessing. + */ + private async removeOrphan( + harnessName: string, + orphans: OrphanHarness[], + action: OrphanAction | undefined, + configIO: ConfigIO, + project: AgentCoreProjectSpec + ): Promise { + if (!action) { + return { + success: false, + error: new ValidationError( + `No changes were made — "${harnessName}" was not deleted. It was created by the preview ` + + `build and is not managed by CloudFormation, so CloudFormation cannot delete it. Removing ` + + `it deletes the resource directly from your AWS account. Re-run with an explicit choice:\n` + + ` --keep delete it from AWS but keep it in agentcore.json (it moves to GA; the ` + + `next \`agentcore deploy\` recreates it under CloudFormation)\n` + + ` --discard delete it from AWS and remove it from agentcore.json (you no longer want it)` + ), + }; + } + + // Delete each recorded orphan resource using its recorded id + ARN-derived region — never + // re-resolve by name. A 404/NotFound means it's already gone, which is success for our + // purposes; any other error aborts so local state still points at the live resource for a + // retry. + for (const orphan of orphans) { try { - const deployedState = await configIO.readDeployedState(); - for (const target of Object.values(deployedState.targets)) { - const deployedHarness = target.resources?.harnesses?.[harnessName]; - if (deployedHarness) { - const targets = await configIO.resolveAWSDeploymentTargets(); - const region = targets[0]?.region; - if (region) { - await deleteHarness({ region, harnessId: deployedHarness.harnessId }); - } - delete target.resources!.harnesses![harnessName]; - await configIO.writeDeployedState(deployedState); - break; - } + await deleteHarness({ region: orphan.region, harnessId: orphan.harnessId }); + } catch (err) { + // 404 = already gone (success). Any other error aborts so local state still points at the + // live resource for a retry. Uses the typed status code, not a message substring. + if (!isHarnessNotFoundError(err)) { + const msg = err instanceof Error ? err.message : String(err); + return { + success: false, + error: toError( + `Failed to delete orphan harness "${harnessName}" (${orphan.harnessId}) in ${orphan.region}: ${msg}. ` + + `Local state was left unchanged — resolve the error and retry.` + ), + }; } - } catch { - // AWS deletion is best-effort; next deploy will clean up } + } - harnesses.splice(harnessIndex, 1); - project.harnesses = harnesses; - - // Remove the associated memory (convention: Memory) - const associatedMemoryName = `${harnessName}Memory`; - if (project.memories) { - project.memories = project.memories.filter(m => m.name !== associatedMemoryName); + // Drop the orphan records from deployed-state so the harness is no longer flagged. + const deployedState = await configIO.readDeployedState().catch(() => undefined); + if (deployedState) { + for (const orphan of orphans) { + const harnesses = deployedState.targets?.[orphan.targetName]?.resources?.harnesses; + if (harnesses) delete harnesses[orphan.name]; } + await configIO.writeDeployedState(deployedState); + } - await this.writeProjectSpec(project, configIO); + // delete-and-discard also removes the spec entry, its memory, and its directory. + // delete-and-keep leaves the spec entry so the next deploy recreates it under CloudFormation. + if (action === 'discard' && (project.harnesses ?? []).some(h => h.name === harnessName)) { + await this.removeFromSpec(harnessName, configIO, project); + } - const pathResolver = configIO.getPathResolver(); - const harnessDir = pathResolver.getHarnessDir(harnessName); - await rm(harnessDir, { recursive: true, force: true }); + return { success: true }; + } - return { success: true }; - } catch (err) { - return { success: false, error: toError(err) }; + /** + * Remove a harness from the project spec: drop its entry, its convention-named memory sibling + * (`Memory`) IF one actually exists, persist agentcore.json, and delete its on-disk directory. + * Managed-memory harnesses own their memory internally (no sibling), so the filter is gated on the + * sibling's actual presence — matching previewRemove and never touching a memory the harness doesn't own. + */ + private async removeFromSpec(harnessName: string, configIO: ConfigIO, project: AgentCoreProjectSpec): Promise { + project.harnesses = (project.harnesses ?? []).filter(h => h.name !== harnessName); + + const associatedMemoryName = `${harnessName}Memory`; + const hasAssociatedMemory = (project.memories ?? []).some(m => m.name === associatedMemoryName); + if (hasAssociatedMemory) { + project.memories = (project.memories ?? []).filter(m => m.name !== associatedMemoryName); } + + await this.writeProjectSpec(project, configIO); + + const pathResolver = configIO.getPathResolver(); + const harnessDir = pathResolver.getHarnessDir(harnessName); + await rm(harnessDir, { recursive: true, force: true }); } async previewRemove(harnessName: string): Promise { @@ -345,30 +627,113 @@ export class HarnessPrimitive extends BasePrimitive { + try { + const configRoot = findConfigRoot(); + if (!configRoot) return false; + const configIO = new ConfigIO({ baseDir: configRoot }); + const deployedState = await configIO.readDeployedState().catch(() => undefined); + return findOrphanHarnesses(deployedState, harnessName).length > 0; + } catch { + return false; + } + } + registerCommands(addCmd: Command, removeCmd: Command): void { addCmd .command('harness') .description('Add a harness to the project') .option('--name ', 'Harness name (start with letter, alphanumeric + underscores, max 48 chars)') - .option('--model-provider ', 'Model provider: bedrock, open_ai, gemini') + .option('--model-provider ', 'Model provider: bedrock, open_ai, gemini, lite_llm') .option('--model-id ', 'Model ID (e.g., anthropic.claude-3-5-sonnet-20240620-v1:0)') .option( '--api-format ', 'API format: converse_stream, responses, chat_completions (bedrock); responses, chat_completions (open_ai)' ) - .option('--api-key-arn ', 'API key ARN for non-Bedrock providers') + .option('--api-key-arn ', 'API key ARN for non-Bedrock providers (optional for lite_llm)') + .option('--api-base ', 'Base URL for the model provider API endpoint (lite_llm only)') + .option( + '--additional-params ', + 'Provider-specific params passed through unchanged, as a JSON object (lite_llm only)' + ) + .option('--temperature ', 'Model sampling temperature (0-2)', strictFloat('--temperature')) + .option('--top-p ', 'Model nucleus-sampling top-p (0-1)', strictFloat('--top-p')) + .option('--top-k ', 'Model top-k sampling (gemini only)', strictInt('--top-k')) + .option( + '--model-max-tokens ', + 'Model-config max output tokens per turn (distinct from --max-tokens)', + strictInt('--model-max-tokens') + ) .option('--container ', 'Container image URI or path to a Dockerfile') .option('--no-memory', 'Skip auto-creating memory') - .option('--max-iterations ', 'Max iterations', parseInt) - .option('--max-tokens ', 'Max tokens', parseInt) - .option('--timeout ', 'Timeout in seconds', parseInt) - .option('--truncation-strategy ', 'Truncation strategy: sliding_window or summarization') + .option('--memory-name ', 'Reference an existing memory by name instead of auto-creating one') + .option('--memory-arn ', 'Reference an existing memory by ARN instead of auto-creating one') + .option('--memory-actor-id ', 'Deploy-time ActorId scoping memory access for the harness') + .option( + '--memory-messages-count ', + 'Number of recent memory messages to load into context', + strictInt('--memory-messages-count') + ) + .option('--memory-top-k ', 'Memory retrieval: items to retrieve per namespace', strictInt('--memory-top-k')) + .option( + '--memory-relevance-score ', + 'Memory retrieval: minimum relevance score (0-1)', + strictFloat('--memory-relevance-score') + ) + // Managed-memory flags — gated behind ENABLE_GATED_FEATURES. When off they still PARSE + // (so explicit use returns a clean "not yet available" error in validation) but are hidden + // from --help, mirroring the AWS Skills gating pattern. + .addOption( + gatedOption(new Option('--memory-mode ', 'Memory mode: managed (default), existing, or disabled')) + ) + .addOption( + gatedOption( + new Option( + '--memory-strategies ', + 'Managed memory strategies (comma-separated): SEMANTIC,SUMMARIZATION,USER_PREFERENCE,EPISODIC' + ) + ) + ) + .addOption( + gatedOption( + new Option('--memory-event-expiry-days ', 'Managed memory event retention in days (3-365)').argParser( + strictInt('--memory-event-expiry-days') + ) + ) + ) + .addOption( + gatedOption(new Option('--memory-encryption-key-arn ', 'Managed memory KMS CMK ARN (create-only)')) + ) + .option('--max-iterations ', 'Max iterations', strictInt('--max-iterations')) + .option('--max-tokens ', 'Max execution tokens per invocation (harness loop cap)', strictInt('--max-tokens')) + .option('--timeout ', 'Timeout in seconds', strictInt('--timeout')) + .option('--truncation-strategy ', 'Truncation strategy: sliding_window, summarization, or none') .option('--network-mode ', 'Network mode: PUBLIC or VPC') .option('--subnets ', 'Comma-separated subnet IDs (for VPC mode)') .option('--security-groups ', 'Comma-separated security group IDs (for VPC mode)') - .option('--idle-timeout ', 'Idle timeout in seconds', parseInt) - .option('--max-lifetime ', 'Max lifetime in seconds', parseInt) + .option('--idle-timeout ', 'Idle timeout in seconds', strictInt('--idle-timeout')) + .option('--max-lifetime ', 'Max lifetime in seconds', strictInt('--max-lifetime')) .option('--session-storage ', 'Mount path for persistent session storage (e.g., /mnt/data/)') + .option( + '--efs-access-point ', + 'EFS access point mount as : (repeatable, VPC mode; max 2)', + collectAccessPoint, + [] + ) + .option( + '--s3-access-point ', + 'S3 Files access point mount as : (repeatable, VPC mode; max 2)', + collectAccessPoint, + [] + ) + .option('--allowed-tools ', 'Comma-separated allow-list of tools the agent may use (e.g. "*" or names)') + .option('--env ', 'Harness environment variable as KEY=VALUE (repeatable)', collectKeyValue, {}) + .option('--tag ', 'Harness-level tag as KEY=VALUE (repeatable)', collectKeyValue, {}) .option('--with-invoke-script', 'Generate a standalone Python invoke script') .option( '--system-prompt ', @@ -380,6 +745,7 @@ export class HarnessPrimitive extends BasePrimitive', 'Remote MCP tool name (required when --tools includes remote_mcp)') .option('--mcp-url ', 'Remote MCP endpoint URL (required when --tools includes remote_mcp)') + .option('--mcp-headers ', 'Remote MCP request headers as a JSON object (with --tools remote_mcp)') .option('--gateway-arn ', 'Gateway ARN (required when --tools includes agentcore_gateway)') .option( '--gateway-outbound-auth ', @@ -387,6 +753,8 @@ export class HarnessPrimitive extends BasePrimitive', 'OAuth provider ARN for gateway outbound auth') .option('--gateway-scopes ', 'Comma-separated OAuth scopes for gateway outbound auth') + .option('--gateway-grant-type ', 'Gateway OAuth grant type: CLIENT_CREDENTIALS or USER_FEDERATION') + .option('--gateway-custom-parameters ', 'Gateway OAuth custom parameters as a JSON object') .option('--authorizer-type ', 'Authorizer type: AWS_IAM or CUSTOM_JWT') .option('--discovery-url ', 'OIDC discovery URL (for CUSTOM_JWT)') .option('--allowed-audience ', 'Comma-separated allowed audiences (for CUSTOM_JWT)') @@ -395,6 +763,35 @@ export class HarnessPrimitive extends BasePrimitive', 'Custom claims JSON array (for CUSTOM_JWT)') .option('--client-id ', 'OAuth client ID (for CUSTOM_JWT)') .option('--client-secret ', 'OAuth client secret (for CUSTOM_JWT)') + .option( + '--private-endpoint-lattice-arn ', + 'PrivateLink: VPC Lattice resource-config id/ARN to reach the OIDC discovery endpoint (for CUSTOM_JWT)' + ) + .option( + '--private-endpoint-vpc-id ', + 'PrivateLink: VPC id for a service-managed endpoint to the OIDC discovery endpoint (for CUSTOM_JWT)' + ) + .option( + '--private-endpoint-subnets ', + 'PrivateLink: comma-separated subnet IDs (with --private-endpoint-vpc-id)' + ) + .option( + '--private-endpoint-ip-type ', + 'PrivateLink: endpoint IP address type: IPV4 or IPV6 (with --private-endpoint-vpc-id)' + ) + .option( + '--private-endpoint-security-groups ', + 'PrivateLink: comma-separated security group IDs, max 5 (with --private-endpoint-vpc-id)' + ) + .option( + '--private-endpoint-routing-domain ', + 'PrivateLink: routing domain (with --private-endpoint-vpc-id)' + ) + .option('--private-endpoint-tags ', 'PrivateLink: tags JSON object (with --private-endpoint-vpc-id)') + .option( + '--private-endpoint-overrides ', + 'PrivateLink: JSON array (max 5) of {domain, privateEndpoint} per-domain overrides (for CUSTOM_JWT)' + ) .option('--json', 'Output as JSON') .action( async (cliOptions: { @@ -403,8 +800,24 @@ export class HarnessPrimitive extends BasePrimitive; + tag?: Record; withInvokeScript?: boolean; systemPrompt?: string; tools?: string; mcpName?: string; mcpUrl?: string; + mcpHeaders?: string; gatewayArn?: string; gatewayOutboundAuth?: string; gatewayProviderArn?: string; gatewayScopes?: string; + gatewayGrantType?: string; + gatewayCustomParameters?: string; authorizerType?: string; discoveryUrl?: string; allowedAudience?: string; @@ -432,6 +853,14 @@ export class HarnessPrimitive extends BasePrimitive { try { @@ -477,34 +906,90 @@ export class HarnessPrimitive extends BasePrimitive | undefined; + if (cliOptions.additionalParams) { + try { + additionalParams = JSON.parse(cliOptions.additionalParams) as Record; + } catch { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: ADDITIONAL_PARAMS_JSON_ERROR })); + } else { + console.error(ADDITIONAL_PARAMS_JSON_ERROR); + } + process.exit(1); + } + } + + const mcpHeaders = this.parseJsonRecordFlag(cliOptions.mcpHeaders, '--mcp-headers', cliOptions.json); + const gatewayCustomParameters = this.parseJsonRecordFlag( + cliOptions.gatewayCustomParameters, + '--gateway-custom-parameters', + cliOptions.json + ); + const result = await this.add({ name: cliOptions.name, modelProvider: provider, modelId, apiFormat: cliOptions.apiFormat as HarnessApiFormat | undefined, apiKeyArn: cliOptions.apiKeyArn, + apiBase: cliOptions.apiBase, + additionalParams, + temperature: cliOptions.temperature, + topP: cliOptions.topP, + topK: cliOptions.topK, + modelMaxTokens: cliOptions.modelMaxTokens, containerUri: containerOption.containerUri, dockerfilePath: containerOption.dockerfilePath, skipMemory: cliOptions.memory === false, + memoryMode: cliOptions.memoryMode as AddHarnessOptions['memoryMode'], + memoryStrategies: cliOptions.memoryStrategies + ?.split(',') + .map(s => s.trim()) + .filter(Boolean), + memoryEventExpiryDays: cliOptions.memoryEventExpiryDays, + memoryEncryptionKeyArn: cliOptions.memoryEncryptionKeyArn, + memoryName: cliOptions.memoryName, + memoryArn: cliOptions.memoryArn, + memoryActorId: cliOptions.memoryActorId, + messagesCount: cliOptions.memoryMessagesCount, + memoryTopK: cliOptions.memoryTopK, + memoryRelevanceScore: cliOptions.memoryRelevanceScore, maxIterations: cliOptions.maxIterations, maxTokens: cliOptions.maxTokens, timeoutSeconds: cliOptions.timeout, - truncationStrategy: cliOptions.truncationStrategy as 'sliding_window' | 'summarization' | undefined, + truncationStrategy: cliOptions.truncationStrategy as + | 'sliding_window' + | 'summarization' + | 'none' + | undefined, networkMode: cliOptions.networkMode as NetworkMode | undefined, subnets: cliOptions.subnets?.split(',').map(s => s.trim()), securityGroups: cliOptions.securityGroups?.split(',').map(s => s.trim()), idleTimeout: cliOptions.idleTimeout, maxLifetime: cliOptions.maxLifetime, sessionStoragePath: cliOptions.sessionStorage, + efsAccessPoints: parseAccessPoints(cliOptions.efsAccessPoint), + s3AccessPoints: parseAccessPoints(cliOptions.s3AccessPoint), + allowedTools: cliOptions.allowedTools + ?.split(',') + .map(s => s.trim()) + .filter(Boolean), + environmentVariables: + cliOptions.env && Object.keys(cliOptions.env).length > 0 ? cliOptions.env : undefined, + tags: cliOptions.tag && Object.keys(cliOptions.tag).length > 0 ? cliOptions.tag : undefined, withInvokeScript: cliOptions.withInvokeScript, systemPrompt: cliOptions.systemPrompt, selectedTools: cliOptions.tools?.split(',').map(s => s.trim()), mcpName: cliOptions.mcpName, mcpUrl: cliOptions.mcpUrl, + mcpHeaders, gatewayArn: cliOptions.gatewayArn, gatewayOutboundAuth: cliOptions.gatewayOutboundAuth as 'awsIam' | 'none' | 'oauth' | undefined, gatewayProviderArn: cliOptions.gatewayProviderArn, gatewayScopes: cliOptions.gatewayScopes?.split(',').map(s => s.trim()), + gatewayGrantType: cliOptions.gatewayGrantType as 'CLIENT_CREDENTIALS' | 'USER_FEDERATION' | undefined, + gatewayCustomParameters, authorizerType: cliOptions.authorizerType as RuntimeAuthorizerType | undefined, jwtConfig: cliOptions.authorizerType === 'CUSTOM_JWT' && cliOptions.discoveryUrl @@ -518,6 +1003,12 @@ export class HarnessPrimitive extends BasePrimitive', 'Name of resource to remove [non-interactive]') + .option('-y, --yes', 'Skip confirmation prompt [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .option( + '--keep', + 'For a preview-build orphan: delete it from AWS but keep it in agentcore.json (it moves to GA; the next deploy recreates it under CloudFormation)' + ) + .option('--discard', 'For a preview-build orphan: delete it from AWS and remove it from agentcore.json') + .action( + async (cliOptions: { name?: string; yes?: boolean; json?: boolean; keep?: boolean; discard?: boolean }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.keep && cliOptions.discard) { + const error = '--keep and --discard are mutually exclusive'; + console.log(JSON.stringify({ success: false, error })); + process.exit(1); + } + const orphanAction: OrphanAction | undefined = cliOptions.keep + ? 'keep' + : cliOptions.discard + ? 'discard' + : undefined; + + // Any flag triggers non-interactive CLI mode + if (cliOptions.name || cliOptions.yes || cliOptions.json || orphanAction) { + if (!cliOptions.name) { + console.log(JSON.stringify({ success: false, error: '--name is required' })); + process.exit(1); + } + + const result = await withCommandRunTelemetry, Result>( + `remove.${this.kind}`, + {}, + () => this.remove(cliOptions.name!, { orphanAction }) + ); + // The orphan no-flag refusal made no changes — surface it as a clean human error on + // stderr (with a non-zero exit) rather than a JSON blob, so a user who expected a + // deletion plainly sees that nothing happened and what to do. Scoped to that exact + // case (orphan + no --keep/--discard, non-JSON); every other path keeps the + // machine-readable JSON convention untouched. + if (!result.success && !orphanAction && !cliOptions.json && (await this.isOrphan(cliOptions.name))) { + console.error(`Error: ${result.error.message}`); + process.exit(1); + } + console.log( + JSON.stringify({ + success: result.success, + resourceType: this.kind, + resourceName: cliOptions.name, + message: result.success ? `Removed ${this.label.toLowerCase()} '${cliOptions.name}'` : undefined, + note: result.success ? SOURCE_CODE_NOTE : undefined, + error: !result.success ? result.error.message : undefined, + }) + ); + process.exit(result.success ? 0 : 1); + } else { + // TUI fallback — dynamic imports to avoid pulling ink (async) into registry + requireTTY(); + const [{ render }, { default: React }, { RemoveFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/remove'), + ]); + const { clear, unmount } = render( + React.createElement(RemoveFlow, { + isInteractive: false, + force: cliOptions.yes, + initialResourceType: this.kind, + initialResourceName: cliOptions.name, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + } + ); } addScreen(): AddScreenComponent { @@ -597,4 +1194,143 @@ export class HarnessPrimitive extends BasePrimitive | undefined { + if (value === undefined) return undefined; + const fail = (msg: string): never => { + if (json) console.log(JSON.stringify({ success: false, error: msg })); + else console.error(msg); + process.exit(1); + }; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return fail(`Invalid ${flag}: not valid JSON`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return fail(`Invalid ${flag}: expected a JSON object of string values`); + } + // CFN headers / custom-parameters are string→string maps. Reject non-string values rather than + // String()-coercing them (which silently turns {"X":{...}} into "[object Object]", {"X":[1,2]} + // into "1,2", etc.) — the user almost certainly wants an error, not a garbage header value. + const entries = Object.entries(parsed as Record); + for (const [k, v] of entries) { + if (typeof v !== 'string') { + return fail(`Invalid ${flag}: value for "${k}" must be a string`); + } + } + return Object.fromEntries(entries) as Record; + } + + /** + * Build the PrivateLink `privateEndpoint` (PrivateLink inbound) from CLI flags. Returns the + * self-managed-lattice arm when --private-endpoint-lattice-arn is set, the managed-vpc arm when + * --private-endpoint-vpc-id is set, or undefined when neither. The schema enforces exactly-one-of + * downstream; this just shapes whichever the user provided. + */ + private buildPrivateEndpointFromFlags(options: { + privateEndpointLatticeArn?: string; + privateEndpointVpcId?: string; + privateEndpointSubnets?: string; + privateEndpointIpType?: string; + privateEndpointSecurityGroups?: string; + privateEndpointRoutingDomain?: string; + privateEndpointTags?: string; + }): PrivateEndpoint | undefined { + if (options.privateEndpointLatticeArn) { + return { selfManagedLatticeResource: { resourceConfigurationIdentifier: options.privateEndpointLatticeArn } }; + } + if (options.privateEndpointVpcId) { + return { + managedVpcResource: { + vpcIdentifier: options.privateEndpointVpcId, + subnetIds: options.privateEndpointSubnets?.split(',').map(s => s.trim()) ?? [], + endpointIpAddressType: options.privateEndpointIpType as EndpointIpAddressType, + ...(options.privateEndpointSecurityGroups && { + securityGroupIds: options.privateEndpointSecurityGroups.split(',').map(s => s.trim()), + }), + ...(options.privateEndpointRoutingDomain && { routingDomain: options.privateEndpointRoutingDomain }), + ...(options.privateEndpointTags && { + tags: JSON.parse(options.privateEndpointTags) as Record, + }), + }, + }; + } + return undefined; + } } diff --git a/src/cli/primitives/KnowledgeBasePrimitive.ts b/src/cli/primitives/KnowledgeBasePrimitive.ts new file mode 100644 index 000000000..e73cb8df4 --- /dev/null +++ b/src/cli/primitives/KnowledgeBasePrimitive.ts @@ -0,0 +1,688 @@ +import { APP_DIR, ValidationError, findConfigRoot, serializeResult, toError } from '../../lib'; +import type { Result } from '../../lib/result'; +import type { + AgentCoreGatewayTarget, + AgentCoreProjectSpec, + ConnectorFileDataSource, + DataSource, + KnowledgeBase, +} from '../../schema'; +import { CONNECTOR_ID, KnowledgeBaseSchema } from '../../schema'; +import { getErrorMessage } from '../errors'; +import { isGatedFeaturesEnabled } from '../feature-flags'; +import { upsertAgenticRetrieveTarget } from '../operations/knowledge-base/agentic-retrieve-upsert'; +import { + type DataSourceTypeFlag, + flagToWireType, + isConnectorConfigType, + readConnectorConfig, +} from '../operations/knowledge-base/connector-config'; +import type { RemovalPreview } from '../operations/remove/types'; +import { runCliCommand } from '../telemetry/cli-command-run.js'; +import { requireTTY } from '../tui/guards/tty'; +import { BasePrimitive } from './BasePrimitive'; +import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { Command } from '@commander-js/extra-typings'; +import { copyFileSync, existsSync, mkdirSync } from 'fs'; +import { basename, dirname, join, relative, resolve } from 'path'; + +/** + * Options for adding a knowledge base resource. + * + * `agentcore add knowledge-base` creates the KB and its data sources. The + * repeatable `--source` flag maps to entries in the KB's `dataSources` array. + * Re-invoking `add` with an existing `--name` appends new data sources to the + * existing entry (idempotent append). + * + * When `--gateway` is set, a connector-typed gateway target referencing this + * KB by name is appended to `agentCoreGateways[name=X].targets[]`. + * + * Binding a pre-existing KB (one this project did not create) is done via the + * gateway-target primitive: `agentcore add gateway-target --type connector + * --connector bedrock-knowledge-bases --knowledge-base-id `. That path + * doesn't touch `knowledgeBases[]` at all. + */ +export interface AddKnowledgeBaseOptions { + name: string; + description?: string; + /** Repeatable `--source` flag values (S3 URIs). Required for S3 data sources. */ + source?: string[]; + /** Repeatable `--connector-config` flag values. Required for non-S3 connectors. */ + connectorConfig?: string[]; + /** `--data-source-type` flag (s3 default, or web-crawler/confluence/...). */ + dataSourceType?: DataSourceTypeFlag; + /** Gateway to wire the KB into via a connector target. Optional. */ + gateway?: string; + json?: boolean; +} + +export interface AddKnowledgeBaseSuccess extends Record { + knowledgeBaseName: string; + /** True if this invocation appended data sources to an existing KB; false on first creation. */ + appended: boolean; + /** New data source URIs added by this invocation (matches the order of --source flags). */ + newDataSources: string[]; + /** Gateway the KB was wired to via a connector target, if any. */ + gatewayWired?: string; +} + +export type RemovableKnowledgeBase = RemovableResource; + +/** + * Cheap shape check for early errors at the CLI boundary. The Zod schema + * (`S3DataSourceSchema`) is the canonical validator and runs on every + * `KnowledgeBaseSchema.parse(...)` and on every `writeProjectSpec` — + * keep these regexes in sync if either is edited. + */ +const S3_URI_PATTERN = /^s3:\/\/[^/]+(\/.*)?$/; + +function isS3Uri(uri: string): boolean { + return S3_URI_PATTERN.test(uri); +} + +/** + * Stable identity key for a data source across the discriminated union: S3 + * sources are keyed by their URI, non-S3 connector sources by their config + * file path. Used for dedup and human-readable summaries. + */ +function dataSourceKey(ds: DataSource): string { + return ds.type === 'S3' ? ds.uri : ds.connectorConfigFile; +} + +/** + * KB primitive. Owns the `agentcore.json` `knowledgeBases[]` lifecycle for + * CLI-managed FMKB knowledge bases. Data sources are either S3 (inline + * `--source` URIs) or non-S3 connectors (`--data-source-type` + + * `--connector-config `, e.g. Web Crawler / Confluence / SharePoint / + * OneDrive / Google Drive). Connector configs are materialized under + * `app//` and referenced by project-relative path. + * + * Existing-KB references — i.e. binding a pre-existing KB that this project + * didn't create — are managed by the gateway-target primitive (`add + * gateway-target --type connector`), since the only artifact written for that + * case is a connector gateway target. + */ +export class KnowledgeBasePrimitive extends BasePrimitive { + readonly kind = 'knowledge-base'; + readonly label = 'Knowledge Base'; + readonly primitiveSchema = KnowledgeBaseSchema; + + async add(options: AddKnowledgeBaseOptions): Promise> { + try { + const sources = options.source ?? []; + const connectorConfigs = options.connectorConfig ?? []; + const wireType = flagToWireType(options.dataSourceType ?? 's3'); + const warnings: string[] = []; + + // Phase 1 — pure validation, no side effects (no file copy). Build the + // would-be data sources for the connector path only after validating the + // config files; for S3 just validate the URIs. The actual file copy is + // deferred to phase 3 so a later validation failure (e.g. missing + // gateway) never leaves a stray file behind. + let buildDataSources: () => DataSource[]; + + if (isConnectorConfigType(wireType)) { + if (sources.length > 0) { + throw new Error(`--source is only valid for S3. For ${wireType}, use --connector-config.`); + } + if (connectorConfigs.length === 0) { + throw new Error(`--connector-config is required for --data-source-type ${options.dataSourceType}.`); + } + // Validate every config file up front (existence, JSON, type match, + // secretArn advisory) before any copy happens. Also detect destination + // basename collisions here: two configs in one invocation that resolve + // to the same `app//` would clobber each other on copy + // (and produce identical connectorConfigFile values the schema rejects), + // so reject the second BEFORE any file is written. Exact-source-path + // duplicates are caught by the batch-dedup loop below with its own + // message; here we only guard distinct sources sharing a basename. + const seenSources = new Set(); + const basenameToSource = new Map(); + for (const cfgPath of connectorConfigs) { + const { warnings: w } = readConnectorConfig(cfgPath, wireType); + warnings.push(...w); + + const resolvedSrc = resolve(cfgPath); + if (seenSources.has(resolvedSrc)) { + // Same source twice — let the batch-dedup loop emit its message. + continue; + } + seenSources.add(resolvedSrc); + const base = basename(resolvedSrc); + const prior = basenameToSource.get(base); + if (prior) { + throw new Error( + `Connector config files '${prior}' and '${cfgPath}' would both be stored as 'app/${options.name}/${base}'. Rename one so their filenames differ.` + ); + } + basenameToSource.set(base, cfgPath); + } + buildDataSources = () => + connectorConfigs.map(cfgPath => { + const stored = this.materializeConnectorConfig(options.name, cfgPath); + return { type: wireType, connectorConfigFile: stored } as ConnectorFileDataSource; + }); + } else { + if (connectorConfigs.length > 0) { + throw new Error('--connector-config is only valid for non-S3 data source types.'); + } + if (sources.length === 0) { + throw new Error('At least one --source is required for S3 data sources.'); + } + // Cheap shape check up front so we error before reading agentcore.json. + // The full bucket-name validation lives in S3DataSourceSchema. + for (const uri of sources) { + if (!isS3Uri(uri)) { + throw new Error(`Invalid S3 URI: ${uri}. Expected s3://bucket[/prefix].`); + } + } + buildDataSources = () => sources.map(uri => ({ type: 'S3', uri })); + } + + // Reject duplicates inside this batch up front (S3 by uri, connector by + // file path). The schema's superRefine catches this too at write time, + // but its generic message is less actionable than naming the offender. + const batchKeys = isConnectorConfigType(wireType) + ? connectorConfigs.map(p => relative(dirname(this.configIO.getConfigRoot()), resolve(p)).split('\\').join('/')) + : sources; + const seenInBatch = new Set(); + for (const key of batchKeys) { + if (seenInBatch.has(key)) { + throw new Error(`Duplicate data source in this invocation: ${key}`); + } + seenInBatch.add(key); + } + + const project = await this.readProjectSpec(); + + // Validate gateway exists (no auto-create) BEFORE any file copy. + if (options.gateway) { + const gw = project.agentCoreGateways.find(g => g.name === options.gateway); + if (!gw) { + throw new Error( + `Gateway "${options.gateway}" not found in agentcore.json. Add it first with 'agentcore add gateway --name ${options.gateway}'.` + ); + } + } + + // Phase 3 — all validation passed; now materialize (copy connector + // configs into app//) and build the data sources. + const newDataSources: DataSource[] = buildDataSources(); + + const existing = project.knowledgeBases.find(kb => kb.name === options.name); + if (existing) { + return await this.appendToExisting(existing, project, newDataSources, options, warnings); + } + + const kb: KnowledgeBase = KnowledgeBaseSchema.parse({ + name: options.name, + ...(options.description && { description: options.description }), + dataSources: newDataSources, + ...(options.gateway && { gateway: options.gateway }), + }); + + project.knowledgeBases.push(kb); + + // --gateway: append the connector targets — one Retrieve per KB plus the + // shared gateway-scoped agentic-retrieve target (this KB gets appended + // to its knowledgeBaseIds[]). + if (options.gateway) { + this.appendConnectorTargets(project, options.gateway, kb.name, kb.name); + } + + await this.writeProjectSpec(project); + + if (!options.json) for (const w of warnings) console.warn(w); + + return { + success: true, + knowledgeBaseName: kb.name, + appended: false, + newDataSources: newDataSources.map(dataSourceKey), + ...(options.gateway && { gatewayWired: options.gateway }), + }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + /** + * Wires a KB into a gateway by emitting BOTH connector targets: + * 1. A bedrock-knowledge-bases target (single-KB Retrieve), and + * 2. The gateway-scoped bedrock-agentic-retrieve target (orchestrated + * fan-out across every KB on the gateway), creating it on first call + * and appending kbReference to its knowledgeBaseIds[] on subsequent + * calls. There's exactly one agentic target per gateway. + * + * `--description` is intentionally not propagated to either target entry. + * `AgentCoreGatewayTargetSchema` doesn't model a per-target description + * (only the parent gateway has one). + */ + private appendConnectorTargets( + project: Awaited>, + gatewayName: string, + retrieveTargetName: string, + kbReference: string + ): void { + const gateway = project.agentCoreGateways.find(g => g.name === gatewayName); + if (!gateway) { + throw new Error(`Gateway "${gatewayName}" not found in agentcore.json.`); + } + this.upsertRetrieveTarget(gateway, retrieveTargetName, kbReference); + upsertAgenticRetrieveTarget(gateway, kbReference); + } + + /** + * Append a single-KB Retrieve target. Idempotent when the same target + * already exists pointing at the same KB; errors if a different target + * with the same name exists. + */ + private upsertRetrieveTarget( + gateway: AgentCoreProjectSpec['agentCoreGateways'][number], + targetName: string, + knowledgeBaseId: string + ): void { + const existingTarget = gateway.targets.find(t => t.name === targetName); + if (existingTarget) { + const sameKb = existingTarget.knowledgeBaseId === knowledgeBaseId; + const sameType = existingTarget.targetType === 'connector'; + const sameConnector = existingTarget.connectorId === CONNECTOR_ID.BEDROCK_KNOWLEDGE_BASES; + if (sameType && sameConnector && sameKb) { + return; + } + throw new Error(`Gateway "${gateway.name}" already has a target named "${targetName}". Pick a different --name.`); + } + const target: AgentCoreGatewayTarget = { + name: targetName, + targetType: 'connector', + connectorId: CONNECTOR_ID.BEDROCK_KNOWLEDGE_BASES, + knowledgeBaseId, + } as AgentCoreGatewayTarget; + gateway.targets.push(target); + } + + /** + * Append data sources to an existing KB entry. Errors loudly on conflicting + * intent (e.g. trying to update description, or duplicate URI). + */ + private async appendToExisting( + existing: KnowledgeBase, + project: Awaited>, + newDataSources: DataSource[], + options: AddKnowledgeBaseOptions, + warnings: string[] = [] + ): Promise> { + // Treat '' and undefined as "no description provided", so a user appending + // a data source without re-passing --description doesn't trip the + // update-not-supported guard. + const descChanged = + options.description !== undefined && options.description !== '' && options.description !== existing.description; + if (descChanged) { + throw new Error( + `Knowledge base "${options.name}" already exists. Update operations are not supported in Wave 1; edit agentcore.json directly to change the description.` + ); + } + if (options.gateway !== undefined && options.gateway !== existing.gateway) { + throw new Error( + `Knowledge base "${options.name}" already exists with a different gateway setting. Update operations are not supported in Wave 1.` + ); + } + + const existingKeys = new Set(existing.dataSources.map(dataSourceKey)); + for (const ds of newDataSources) { + const key = dataSourceKey(ds); + if (existingKeys.has(key)) { + throw new Error(`Data source "${key}" already exists on knowledge-base "${options.name}".`); + } + } + + existing.dataSources.push(...newDataSources); + + // If the KB already has a gateway set and the connector target hasn't been + // appended yet (e.g. it was added before Wave 2 went live), append it now. + if (existing.gateway) { + this.appendConnectorTargets(project, existing.gateway, existing.name, existing.name); + } + + await this.writeProjectSpec(project); + + if (!options.json) for (const w of warnings) console.warn(w); + + return { + success: true, + knowledgeBaseName: existing.name, + appended: true, + newDataSources: newDataSources.map(dataSourceKey), + ...(existing.gateway && { gatewayWired: existing.gateway }), + }; + } + + /** + * Ensure the connector-config file lives under `app//` and return + * its project-root-relative path. If the user's path already points inside + * that folder, return it as-is; otherwise copy it in (announced to the user). + */ + private materializeConnectorConfig(kbName: string, cfgPath: string): string { + const projectRoot = dirname(this.configIO.getConfigRoot()); + const src = resolve(cfgPath); + if (!existsSync(src)) { + throw new Error(`Connector config file not found: ${cfgPath}`); + } + const destDir = join(projectRoot, APP_DIR, kbName); + const dest = join(destDir, basename(src)); + const relToProject = (p: string) => relative(projectRoot, p).split('\\').join('/'); + + if (resolve(src) === resolve(dest)) { + return relToProject(dest); + } + // Defense-in-depth: never silently overwrite a different file already at + // the destination (e.g. a prior data source on this KB whose config shares + // this basename). The in-place case above already returned, so reaching + // here with an existing dest means src !== dest. + if (existsSync(dest)) { + throw new Error( + `Connector config '${cfgPath}' would overwrite the existing file at '${relToProject(dest)}'. Rename it so its filename differs.` + ); + } + mkdirSync(destDir, { recursive: true }); + copyFileSync(src, dest); + console.error(`Copied connector config to ${relToProject(dest)}`); + return relToProject(dest); + } + + async remove(name: string): Promise { + try { + const project = await this.readProjectSpec(); + + // Find the KB entry. Cascade-remove the per-KB Retrieve target on the + // linked gateway, if any. + const idx = project.knowledgeBases.findIndex(kb => kb.name === name); + if (idx === -1) { + throw new Error(`Knowledge base "${name}" not found.`); + } + const kb = project.knowledgeBases[idx]!; + project.knowledgeBases.splice(idx, 1); + if (kb.gateway) { + this.removeConnectorTarget(project, kb.gateway, kb.name); + } + + // Cascade-prune the removed KB out of every gateway's agentic-retrieve + // target. Without this, the spec would be unwriteable: the cross-spec + // validator rejects an agentic target with a knowledgeBaseIds[] entry + // that doesn't match a knowledgeBases[] name and isn't a literal KB id. + // We keep the no-update rule for renames; remove is the one shape where + // doing nothing leaves the spec in a state the schema won't write. + this.pruneAgenticRetrieveReferences(project, name); + + await this.writeProjectSpec(project); + return { success: true }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + async previewRemove(name: string): Promise { + const project = await this.readProjectSpec(); + + const kb = project.knowledgeBases.find(k => k.name === name); + if (!kb) { + throw new Error(`Knowledge base "${name}" not found.`); + } + const summary: string[] = [ + `Removing knowledge base: ${name}`, + ` Data sources (${kb.dataSources.length}):`, + ...kb.dataSources.map(ds => ` - ${dataSourceKey(ds)}`), + ]; + if (kb.gateway) { + summary.push(` Gateway target: '${name}' on '${kb.gateway}' will be removed`); + } + + const afterSpec = JSON.parse(JSON.stringify(project)) as typeof project; + afterSpec.knowledgeBases = afterSpec.knowledgeBases.filter(k => k.name !== name); + if (kb.gateway) { + const gw = afterSpec.agentCoreGateways.find(g => g.name === kb.gateway); + if (gw) gw.targets = gw.targets.filter(t => t.name !== name); + } + const pruneActions = this.pruneAgenticRetrieveReferences(afterSpec, name); + for (const action of pruneActions) { + if (action.removedTarget) { + summary.push( + ` Gateway "${action.gatewayName}" agentic-retrieve target '${action.targetName}' will be removed (was the last KB)` + ); + } else { + summary.push( + ` Gateway "${action.gatewayName}" agentic-retrieve target '${action.targetName}' will lose KB '${name}'` + ); + } + } + + return { + summary, + directoriesToDelete: [], + schemaChanges: [{ file: 'agentcore/agentcore.json', before: project, after: afterSpec }], + }; + } + + /** + * Walk every gateway's agentic-retrieve target and drop kbReference from + * its knowledgeBaseIds[]. If the array empties out, remove the agentic + * target itself — schema requires non-empty knowledgeBaseIds[]. Returns + * a list of actions for callers that want to surface what changed. + */ + private pruneAgenticRetrieveReferences( + project: AgentCoreProjectSpec, + kbReference: string + ): { gatewayName: string; targetName: string; removedTarget: boolean }[] { + const actions: { gatewayName: string; targetName: string; removedTarget: boolean }[] = []; + for (const gw of project.agentCoreGateways) { + const agenticIdx = gw.targets.findIndex( + t => t.targetType === 'connector' && t.connectorId === CONNECTOR_ID.BEDROCK_AGENTIC_RETRIEVE + ); + if (agenticIdx === -1) continue; + const agentic = gw.targets[agenticIdx]!; + const ids = agentic.knowledgeBaseIds ?? []; + if (!ids.includes(kbReference)) continue; + const remaining = ids.filter(id => id !== kbReference); + if (remaining.length === 0) { + gw.targets.splice(agenticIdx, 1); + actions.push({ gatewayName: gw.name, targetName: agentic.name, removedTarget: true }); + } else { + agentic.knowledgeBaseIds = remaining; + actions.push({ gatewayName: gw.name, targetName: agentic.name, removedTarget: false }); + } + } + return actions; + } + + async getRemovable(): Promise { + try { + const project = await this.readProjectSpec(); + return project.knowledgeBases.map(kb => ({ name: kb.name })); + } catch { + return []; + } + } + + /** + * Remove a connector-typed gateway target by name. No-op if the target or + * gateway is missing — that's fine because we may be cascading from a KB + * whose gateway link was unwired manually. + */ + private removeConnectorTarget( + project: Awaited>, + gatewayName: string, + targetName: string + ): void { + const gateway = project.agentCoreGateways.find(g => g.name === gatewayName); + if (!gateway) return; + gateway.targets = gateway.targets.filter(t => t.name !== targetName); + } + + registerCommands(addCmd: Command, removeCmd: Command): void { + addCmd + .command(this.kind, { hidden: !isGatedFeaturesEnabled() }) + .description('Add a knowledge base (FMKB) to the project, optionally wiring it to a gateway.') + .option('--name ', 'Knowledge base name (1-48 chars, starts with letter)') + .option('--description ', 'Optional description (used for tool discovery)') + .option( + '--source ', + 'S3 URI for a data source (s3://bucket[/prefix]). Repeatable for multiple data sources.', + (val: string, acc: string[]) => [...acc, val], + [] as string[] + ) + .option( + '--data-source-type ', + 'Data source type: s3 (default), web-crawler, confluence, sharepoint, onedrive, google-drive', + 's3' + ) + .option( + '--connector-config ', + 'Path to a JSON connector-config file (required for non-S3 types). Repeatable.', + (val: string, acc: string[]) => [...acc, val], + [] as string[] + ) + .option('--gateway ', 'Gateway to attach the KB to as a connector target.') + .option('--json', 'Output as JSON [non-interactive]') + .action( + async (cliOptions: { + name?: string; + description?: string; + source?: string[]; + dataSourceType?: string; + connectorConfig?: string[]; + gateway?: string; + json?: boolean; + }) => { + if (!isGatedFeaturesEnabled()) { + console.error('Knowledge bases are not yet available.'); + process.exit(1); + } + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + // No-args (or only --json) → drop into the Add Knowledge Base TUI + // wizard so the surface matches `agentcore add agent` / + // `add memory`. --data-source-type defaults to 's3' from + // Commander, so it's always populated; check the user-supplied + // flags only. + const userPassedAnyFlag = + !!cliOptions.name || + !!cliOptions.description || + (cliOptions.source?.length ?? 0) > 0 || + (cliOptions.connectorConfig?.length ?? 0) > 0 || + !!cliOptions.gateway || + !!cliOptions.json; + if (!userPassedAnyFlag) { + try { + requireTTY(); + const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/add/AddFlow'), + ]); + const { clear, unmount } = render( + React.createElement(AddFlow, { + isInteractive: false, + initialResource: 'knowledge-base', + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + return; + } catch (error) { + console.error(getErrorMessage(error)); + process.exit(1); + } + } + + await runCliCommand('add.knowledge-base', !!cliOptions.json, async () => { + if (!cliOptions.name) { + throw new ValidationError('A --name is required for `agentcore add knowledge-base`.'); + } + + const result = await this.add({ + name: cliOptions.name, + description: cliOptions.description, + source: cliOptions.source, + dataSourceType: cliOptions.dataSourceType as DataSourceTypeFlag | undefined, + connectorConfig: cliOptions.connectorConfig, + gateway: cliOptions.gateway, + json: cliOptions.json, + }); + + if (!result.success) { + throw result.error; + } + + if (cliOptions.json) { + console.log(JSON.stringify(serializeResult(result))); + } else if (result.appended) { + for (const uri of result.newDataSources) { + console.log(`Added data source '${uri}' to knowledge-base '${result.knowledgeBaseName}'`); + } + if (result.gatewayWired) { + console.log(` (gateway '${result.gatewayWired}' connector target ensured)`); + } + } else { + console.log(`Added knowledge base '${result.knowledgeBaseName}'`); + for (const uri of result.newDataSources) { + console.log(` with data source '${uri}'`); + } + if (result.gatewayWired) { + console.log(` wired to gateway '${result.gatewayWired}' as connector target`); + } + } + + return { + data_source_count: result.newDataSources.length, + data_source_type: cliOptions.dataSourceType ?? 's3', + has_description: !!cliOptions.description, + has_gateway: !!cliOptions.gateway, + is_append: result.appended, + }; + }); + } + ); + + removeCmd + .command(this.kind, { hidden: !isGatedFeaturesEnabled() }) + .description('Remove a knowledge base from the project') + .option('--name ', 'Knowledge base name') + .option('--json', 'Output as JSON [non-interactive]') + .action(async (cliOptions: { name?: string; json?: boolean }) => { + if (!isGatedFeaturesEnabled()) { + console.error('Knowledge bases are not yet available.'); + process.exit(1); + } + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + await runCliCommand('remove.knowledge-base', !!cliOptions.json, async () => { + if (!cliOptions.name) { + throw new ValidationError('A --name is required for `agentcore remove knowledge-base`.'); + } + const result = await this.remove(cliOptions.name); + if (!result.success) { + throw result.error; + } + if (cliOptions.json) { + console.log(JSON.stringify(serializeResult(result))); + } else { + console.log(`Removed knowledge base '${cliOptions.name}'`); + } + return {}; + }); + }); + } + + addScreen(): AddScreenComponent { + // Wave 1: CLI-only. TUI lands in Plan C. + return null; + } +} diff --git a/src/cli/primitives/OnlineEvalConfigPrimitive.ts b/src/cli/primitives/OnlineEvalConfigPrimitive.ts index 1624ab790..4886ce46a 100644 --- a/src/cli/primitives/OnlineEvalConfigPrimitive.ts +++ b/src/cli/primitives/OnlineEvalConfigPrimitive.ts @@ -17,6 +17,8 @@ export interface AddOnlineEvalConfigOptions { samplingRate: number; enableOnCreate?: boolean; endpoint?: string; + logGroupNames?: string[]; + serviceNames?: string[]; } export type RemovableOnlineEvalConfig = RemovableResource; @@ -65,10 +67,13 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive 0) { + summary.push(`Uses evaluators: ${config.evaluators.join(', ')}`); + } + if (config.insights && config.insights.length > 0) { + summary.push(`Uses insights: ${config.insights.join(', ')}`); + } const schemaChanges: SchemaChange[] = []; const afterSpec = { @@ -113,6 +118,11 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive', 'Evaluator ARN(s) [non-interactive]') .option('--sampling-rate ', 'Sampling percentage (0.01-100) [non-interactive]') .option('--endpoint ', 'Runtime endpoint name to scope monitoring [non-interactive]') + .option( + '--log-group-name ', + 'CloudWatch log group name(s) for custom data sources (1-5) [non-interactive]' + ) + .option('--service-name ', 'Service name(s) to filter traces for custom data sources [non-interactive]') .option('--enable-on-create', 'Enable evaluation immediately after deploy [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') .action( @@ -123,6 +133,8 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive { @@ -136,12 +148,33 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive { - if (!cliOptions.name || !cliOptions.runtime || allEvaluators.length === 0 || !cliOptions.samplingRate) { + // Mutual exclusivity: --runtime and --log-group-name cannot be used together + if (cliOptions.runtime && cliOptions.logGroupName) { throw new Error( - '--name, --runtime, --evaluator (and/or --evaluator-arn), and --sampling-rate are all required in non-interactive mode' + 'Error: --runtime and --log-group-name are mutually exclusive. Use --runtime (+ optional --endpoint) for AgentCore agents, or --log-group-name/--service-name for custom data sources.' ); } + // Validate required fields based on source mode + if (cliOptions.logGroupName) { + // Custom data source mode + if (!cliOptions.name || allEvaluators.length === 0 || !cliOptions.samplingRate) { + throw new Error( + '--name, --log-group-name, --evaluator (and/or --evaluator-arn), and --sampling-rate are all required in non-interactive mode' + ); + } + if (cliOptions.logGroupName.length > 5) { + throw new Error('--log-group-name accepts at most 5 log group names'); + } + } else { + // AgentCore runtime mode + if (!cliOptions.name || !cliOptions.runtime || allEvaluators.length === 0 || !cliOptions.samplingRate) { + throw new Error( + '--name, --runtime, --evaluator (and/or --evaluator-arn), and --sampling-rate are all required in non-interactive mode' + ); + } + } + // Sampling rate as a percentage of requests to evaluate (0.01% to 100%) const samplingRate = parseFloat(cliOptions.samplingRate); if (isNaN(samplingRate) || samplingRate < 0.01 || samplingRate > 100) { @@ -152,11 +185,13 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive r.name === options.agent); if (!runtime) { throw new Error(`Runtime "${options.agent}" not found in project.`); @@ -231,11 +266,13 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive { + readonly kind = 'online-insights' as const; + readonly label = 'Online Insights Config'; + override readonly article = 'an'; + readonly primitiveSchema = OnlineEvalConfigSchema; + + async add(options: AddOnlineInsightsOptions): Promise> { + try { + const config = await this.createOnlineInsightsConfig(options); + return { success: true, configName: config.name }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + async remove(configName: string): Promise { + try { + const project = await this.readProjectSpec(); + + const index = project.onlineEvalConfigs.findIndex( + c => c.name === configName && c.insights && c.insights.length > 0 + ); + if (index === -1) { + return { + success: false, + error: new ResourceNotFoundError(`Online insights config "${configName}" not found.`), + }; + } + + project.onlineEvalConfigs.splice(index, 1); + await this.writeProjectSpec(project); + + return { success: true }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + async previewRemove(configName: string): Promise { + const project = await this.readProjectSpec(); + + const config = project.onlineEvalConfigs.find(c => c.name === configName && c.insights && c.insights.length > 0); + if (!config) { + throw new Error(`Online insights config "${configName}" not found.`); + } + + const summary: string[] = [ + `Removing online insights config: ${configName}`, + `Uses insights: ${(config.insights ?? []).join(', ')}`, + ]; + const schemaChanges: SchemaChange[] = []; + + const afterSpec = { + ...project, + onlineEvalConfigs: project.onlineEvalConfigs.filter(c => c.name !== configName), + }; + + schemaChanges.push({ + file: 'agentcore/agentcore.json', + before: project, + after: afterSpec, + }); + + return { summary, directoriesToDelete: [], schemaChanges }; + } + + async getRemovable(): Promise { + try { + const project = await this.readProjectSpec(); + return project.onlineEvalConfigs.filter(c => c.insights && c.insights.length > 0).map(c => ({ name: c.name })); + } catch { + return []; + } + } + + async getAllNames(): Promise { + try { + const project = await this.readProjectSpec(); + return project.onlineEvalConfigs.filter(c => c.insights && c.insights.length > 0).map(c => c.name); + } catch { + return []; + } + } + + registerCommands(addCmd: Command, removeCmd: Command): void { + addCmd + .command('online-insights') + .description('Add an online insights config to the project') + .option('--name ', 'Config name [non-interactive]') + .option('-r, --runtime ', 'Runtime to monitor [non-interactive]') + .option('--insights ', 'Insight IDs (e.g. Builtin.Insight.FailureAnalysis) [non-interactive]') + .option('--sampling-rate ', 'Sampling percentage (0.01-100) [non-interactive]') + .option( + '--clustering-frequency ', + 'Clustering frequencies: DAILY, WEEKLY, MONTHLY [non-interactive]' + ) + .option('--endpoint ', 'Runtime endpoint name to scope monitoring [non-interactive]') + .option('--enable-on-create', 'Enable insights immediately after deploy [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .action( + async (cliOptions: { + name?: string; + runtime?: string; + insights?: string[]; + samplingRate?: string; + clusteringFrequency?: string[]; + endpoint?: string; + enableOnCreate?: boolean; + json?: boolean; + }) => { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.name || cliOptions.json) { + await runCliCommand('add.online-insights', !!cliOptions.json, async () => { + if (!cliOptions.name || !cliOptions.runtime || !cliOptions.insights?.length || !cliOptions.samplingRate) { + throw new Error( + '--name, --runtime, --insights, and --sampling-rate are all required in non-interactive mode' + ); + } + + const samplingRate = parseFloat(cliOptions.samplingRate); + if (isNaN(samplingRate) || samplingRate < 0.01 || samplingRate > 100) { + throw new Error( + `Invalid --sampling-rate "${cliOptions.samplingRate}". Must be a percentage between 0.01 and 100` + ); + } + + const result = await this.add({ + name: cliOptions.name, + agent: cliOptions.runtime, + insights: cliOptions.insights, + samplingRate, + clusteringFrequencies: cliOptions.clusteringFrequency, + enableOnCreate: cliOptions.enableOnCreate, + endpoint: cliOptions.endpoint, + }); + + if (!result.success) { + throw result.error; + } + + if (cliOptions.json) { + console.log(JSON.stringify(serializeResult(result))); + } else { + console.log(`Added online insights config '${result.configName}'`); + } + + return { + insights_count: cliOptions.insights.length, + enable_on_create: cliOptions.enableOnCreate ?? false, + }; + }); + } else { + try { + requireTTY(); + const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/add/AddFlow'), + ]); + const { clear, unmount } = render( + React.createElement(AddFlow, { + isInteractive: false, + initialResource: 'online-insights', + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } catch (error) { + console.error(getErrorMessage(error)); + process.exit(1); + } + } + } + ); + + this.registerRemoveSubcommand(removeCmd); + } + + addScreen(): AddScreenComponent { + return null; + } + + private async createOnlineInsightsConfig(options: AddOnlineInsightsOptions): Promise { + const project = await this.readProjectSpec(); + + this.checkDuplicate(project.onlineEvalConfigs, options.name, 'Online insights config'); + + validateInsightIds(options.insights); + + // Validate that the endpoint exists on the specified runtime if provided + if (options.endpoint) { + const runtime = project.runtimes.find(r => r.name === options.agent); + if (!runtime) { + throw new Error(`Runtime "${options.agent}" not found in project.`); + } + if (!runtime.endpoints?.[options.endpoint]) { + throw new Error( + `Endpoint "${options.endpoint}" not found on runtime "${options.agent}". Available endpoints: ${ + runtime.endpoints ? Object.keys(runtime.endpoints).join(', ') : '(none)' + }` + ); + } + } + + const config: OnlineEvalConfig = { + name: options.name, + agent: options.agent, + insights: options.insights, + samplingRate: options.samplingRate, + ...(options.clusteringFrequencies?.length && { + clusteringConfig: { + frequencies: options.clusteringFrequencies as ('DAILY' | 'WEEKLY' | 'MONTHLY')[], + }, + }), + ...(options.enableOnCreate !== undefined && { enableOnCreate: options.enableOnCreate }), + ...(options.endpoint && { endpoint: options.endpoint }), + }; + + project.onlineEvalConfigs.push(config); + await this.writeProjectSpec(project); + + return config; + } +} + +/** Validates that each insight ID starts with `Builtin.Insight.` or is a full ARN. */ +export function validateInsightIds(insights: string[]): void { + for (const insight of insights) { + if (!insight.startsWith('Builtin.Insight.') && !insight.startsWith('arn:')) { + throw new ValidationError( + `Invalid insight "${insight}". Must be a Builtin.Insight.* identifier (e.g. Builtin.Insight.FailureAnalysis) or a full ARN.` + ); + } + } +} diff --git a/src/cli/primitives/PaymentConnectorPrimitive.ts b/src/cli/primitives/PaymentConnectorPrimitive.ts index 9254b0c32..4fa90a68a 100644 --- a/src/cli/primitives/PaymentConnectorPrimitive.ts +++ b/src/cli/primitives/PaymentConnectorPrimitive.ts @@ -314,7 +314,7 @@ export class PaymentConnectorPrimitive extends BasePrimitive', 'Payment manager name [non-interactive]') .option('--name ', 'Payment connector name [non-interactive]') .option('--provider ', 'Payment provider: CoinbaseCDP, StripePrivy [non-interactive]') @@ -522,7 +522,7 @@ export class PaymentConnectorPrimitive extends BasePrimitive', 'Name of connector to remove [non-interactive]') .option('--manager ', 'Payment manager name [non-interactive]') .option('-y, --yes', 'Skip confirmation prompt [non-interactive]') diff --git a/src/cli/primitives/PaymentManagerPrimitive.ts b/src/cli/primitives/PaymentManagerPrimitive.ts index c3bfe6bbc..90fe3ef9e 100644 --- a/src/cli/primitives/PaymentManagerPrimitive.ts +++ b/src/cli/primitives/PaymentManagerPrimitive.ts @@ -322,7 +322,7 @@ export class PaymentManagerPrimitive extends BasePrimitive', 'Payment manager name [non-interactive]') .option('--authorizer-type ', 'Authorizer type: AWS_IAM or CUSTOM_JWT (default: AWS_IAM) [non-interactive]') .option('--discovery-url ', 'OIDC discovery URL (required for CUSTOM_JWT) [non-interactive]') @@ -494,7 +494,7 @@ export class PaymentManagerPrimitive extends BasePrimitive', 'Name of resource to remove [non-interactive]') .option('-y, --yes', 'Skip confirmation prompt [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') diff --git a/src/cli/primitives/PolicyEnginePrimitive.ts b/src/cli/primitives/PolicyEnginePrimitive.ts index 44b4b5a3f..bb4cdf306 100644 --- a/src/cli/primitives/PolicyEnginePrimitive.ts +++ b/src/cli/primitives/PolicyEnginePrimitive.ts @@ -135,11 +135,14 @@ export class PolicyEnginePrimitive extends BasePrimitive { try { const project = await this.readProjectSpec(); - return project.agentCoreGateways.filter(gw => !gw.policyEngineConfiguration).map(gw => gw.name); + return project.agentCoreGateways + .filter(gw => gw.protocolType === 'None' && !gw.policyEngineConfiguration) + .map(gw => gw.name); } catch { return []; } @@ -181,15 +184,22 @@ export class PolicyEnginePrimitive extends BasePrimitive> { try { - const deployedState = await this.configIO.readDeployedState(); + const [deployedState, project] = await Promise.all([this.configIO.readDeployedState(), this.readProjectSpec()]); + const mcpGatewayNames = new Set( + project.agentCoreGateways.filter(gw => gw.protocolType !== 'None').map(gw => gw.name) + ); const result: Record = {}; for (const target of Object.values(deployedState.targets)) { - const gateways = target.resources?.mcp?.gateways; + const gateways = target.resources?.mcp?.gateways ?? target.resources?.gateways; if (gateways) { for (const [name, gw] of Object.entries(gateways)) { - if (gw?.gatewayArn) { + if (gw?.gatewayArn && !mcpGatewayNames.has(name)) { result[name] = gw.gatewayArn; } } @@ -201,6 +211,26 @@ export class PolicyEnginePrimitive extends BasePrimitive { + try { + const project = await this.readProjectSpec(); + return project.agentCoreGateways + .filter(gw => gw.protocolType === 'None') + .map(gw => ({ + name: gw.name, + httpTargets: (gw.targets || []) + .filter((t: { targetType?: string }) => t.targetType === 'httpRuntime') + .map((t: { name: string }) => t.name), + })); + } catch { + return []; + } + } + registerCommands(addCmd: Command, removeCmd: Command): void { addCmd .command('policy-engine') diff --git a/src/cli/primitives/PolicyPrimitive.ts b/src/cli/primitives/PolicyPrimitive.ts index 4cce4df34..f28bf2d7c 100644 --- a/src/cli/primitives/PolicyPrimitive.ts +++ b/src/cli/primitives/PolicyPrimitive.ts @@ -1,17 +1,20 @@ import { ResourceNotFoundError, ValidationError, findConfigRoot, serializeResult, toError } from '../../lib'; import type { Result } from '../../lib/result'; import type { Policy } from '../../schema'; -import { PolicySchema, ValidationModeSchema } from '../../schema'; +import { EnforcementModeSchema, PolicySchema, ValidationModeSchema } from '../../schema'; import { detectRegion } from '../aws'; import { getPolicyGeneration, startPolicyGeneration } from '../aws/policy-generation'; import { getErrorMessage } from '../errors'; +import { isGatedFeaturesEnabled } from '../feature-flags'; import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { runCliCommand, withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; import { PolicyValidationMode, standardize } from '../telemetry/schemas/common-shapes.js'; import { requireTTY } from '../tui/guards/tty'; +import { type PolicyEffect, authorizationPhaseForEffect, defaultDataPathForEffect } from '../tui/screens/policy/types'; import { BasePrimitive } from './BasePrimitive'; import { SOURCE_CODE_NOTE } from './constants'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import { Option } from '@commander-js/extra-typings'; import type { Command } from '@commander-js/extra-typings'; import { existsSync, readFileSync } from 'fs'; @@ -24,6 +27,8 @@ export interface AddPolicyOptions { generate?: string; gateway?: string; validationMode?: 'FAIL_ON_ANY_FINDINGS' | 'IGNORE_ALL_FINDINGS'; + enforcementMode?: 'ACTIVE' | 'LOG_ONLY'; + authorizationPhase?: 'INITIATE' | 'RETURN_OUTPUT'; } export interface RemovablePolicyResource extends RemovableResource { @@ -142,6 +147,8 @@ export class PolicyPrimitive extends BasePrimitive(option: T): T => (isGatedFeaturesEnabled() ? option : option.hideHelp()); + addCmd .command('policy') .description('Add a policy to a policy engine') .option('--name ', 'Policy name [non-interactive]') .option('--engine ', 'Policy engine name [non-interactive]') .option('--description ', 'Policy description [non-interactive]') - .option('--source ', 'Path to a Cedar policy file [non-interactive]') - .option('--statement ', 'Cedar policy statement [non-interactive]') - .option('-g, --generate ', 'Generate Cedar policy from natural language description [non-interactive]') + .option('--source ', 'Path to a policy file [non-interactive]') + .option('--statement ', 'Policy statement [non-interactive]') + .option('-g, --generate ', 'Generate policy from natural language description [non-interactive]') .option('--gateway ', 'Deployed gateway name for policy generation [non-interactive]') + // Guardrail form flags are gated behind ENABLE_GATED_FEATURES: hidden from help when off. + .addOption(gate(new Option('--target ', 'Gateway target name for Cedar action scope [non-interactive]'))) + .addOption( + gate( + new Option( + '--form-category ', + 'Guardrail category: contentFilter, promptAttack, or sensitiveInformation [non-interactive]' + ) + ) + ) + .addOption( + gate(new Option('--form-filters ', 'Comma-separated filters for the chosen category [non-interactive]')) + ) + .addOption( + gate( + new Option( + '--form-effect ', + 'Policy effect: forbid, permit, or suppressOutput (default: forbid) [non-interactive]' + ) + ) + ) + .addOption( + gate( + new Option( + '--form-data-path ', + 'Data path to evaluate, e.g. context.input.prompt (default: context.input.prompt) [non-interactive]' + ) + ) + ) .option( '--validation-mode ', 'Validation mode: FAIL_ON_ANY_FINDINGS or IGNORE_ALL_FINDINGS [non-interactive]' ) + .option( + '--enforcement-mode ', + 'Enforcement mode: ACTIVE (enforce decisions) or LOG_ONLY (shadow mode) (default: ACTIVE) [non-interactive]' + ) .option('--json', 'Output as JSON [non-interactive]') .action( async (cliOptions: { @@ -304,7 +347,13 @@ export class PolicyPrimitive extends BasePrimitive { if (!findConfigRoot()) { @@ -318,6 +367,7 @@ export class PolicyPrimitive extends BasePrimitive { @@ -328,7 +378,28 @@ export class PolicyPrimitive extends BasePrimitive 1) { + throw new Error('Only one of --statement, --source, --generate, or --form-* can be provided.'); + } + + if (cliOptions.enforcementMode && !EnforcementModeSchema.safeParse(cliOptions.enforcementMode).success) { + throw new Error('Invalid --enforcement-mode. Use ACTIVE or LOG_ONLY.'); + } + + // Handle form mode: synthesize Cedar from category/filters/thresholds + let effectiveOptions: AddPolicyOptions = { name: cliOptions.name, engine: cliOptions.engine, description: cliOptions.description, @@ -339,7 +410,83 @@ export class PolicyPrimitive extends BasePrimitive s.trim()); + + let resolvedGatewayArn: string | undefined; + let resolvedTargetName: string | undefined = cliOptions.target; + if (cliOptions.gateway) { + try { + const deployedState = await this.configIO.readDeployedState(); + for (const target of Object.values(deployedState.targets)) { + const gateways = target.resources?.mcp?.gateways ?? target.resources?.gateways; + if (gateways) { + const gw = gateways[cliOptions.gateway]; + if (gw?.gatewayArn) { + resolvedGatewayArn = gw.gatewayArn; + if (!resolvedTargetName) { + const gwTargets = gw.targets; + if (gwTargets) { + const targetNames = Object.keys(gwTargets); + if (targetNames.length === 1) { + resolvedTargetName = targetNames[0]; + } else if (targetNames.length > 1) { + throw new ValidationError( + `Multiple targets found on gateway "${cliOptions.gateway}": ${targetNames.join(', ')}. Use --target to specify one.` + ); + } + } + } + break; + } + } + } + } catch (e) { + if (e instanceof ValidationError) throw e; // intentional validation — re-raise + // deployed state not available — ARN will be omitted + } + } + + const { synthesizeCedar } = await import('../tui/screens/policy/synthesize-cedar'); + + const statement = synthesizeCedar( + { + category: cliOptions.formCategory as 'contentFilter' | 'promptAttack' | 'sensitiveInformation', + filters, + effect: policyEffect, + dataPath: cliOptions.formDataPath ?? defaultDataPathForEffect(policyEffect), + }, + { targetName: resolvedTargetName, gatewayArn: resolvedGatewayArn } + ); + // Output-phase effects (suppressOutput) must register on RETURN_OUTPUT. + effectiveOptions = { + ...effectiveOptions, + statement, + authorizationPhase: authorizationPhaseForEffect(policyEffect), + }; + } + + const result = await this.add(effectiveOptions); if (!result.success) { throw result.error; @@ -351,11 +498,13 @@ export class PolicyPrimitive extends BasePrimitive ({ - ConfigIO: class { - readProjectSpec = mockReadProjectSpec; - writeProjectSpec = mockWriteProjectSpec; - }, - findConfigRoot: () => '/fake/root', - toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), - serializeResult: (r: unknown) => r, - ResourceNotFoundError: class extends Error { - constructor(m: string) { - super(m); - this.name = 'ResourceNotFoundError'; - } - }, -})); - -function makeProject(abTests: { name: string; gatewayRef?: string }[] = []) { - return { - name: 'TestProject', - version: 1, - managedBy: 'CDK' as const, - runtimes: [], - memories: [], - credentials: [], - evaluators: [], - onlineEvalConfigs: [], - agentCoreGateways: [], - policyEngines: [], - configBundles: [], - abTests, - httpGateways: [] as { name: string; runtimeRef: string }[], - }; -} - -const validOptions: AddABTestOptions = { - name: 'MyTest', - agent: 'my-agent', - controlBundle: 'arn:bundle:control', - controlVersion: 'v1', - treatmentBundle: 'arn:bundle:treatment', - treatmentVersion: 'v1', - controlWeight: 80, - treatmentWeight: 20, - onlineEval: 'arn:eval:config', -}; - -let primitive: ABTestPrimitive; - -describe('ABTestPrimitive', () => { - beforeEach(() => { - vi.clearAllMocks(); - primitive = new ABTestPrimitive(); - }); - - it('has correct kind, label, and article', () => { - expect(primitive.kind).toBe('ab-test'); - expect(primitive.label).toBe('AB Test'); - // eslint-disable-next-line @typescript-eslint/dot-notation - expect(primitive['article']).toBe('an'); - }); - - describe('add', () => { - it('adds AB test to project spec and returns success', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject()); - mockWriteProjectSpec.mockResolvedValue(undefined); - - const result = await primitive.add(validOptions); - - expect(result.success).toBe(true); - expect(result).toHaveProperty('abTestName', 'MyTest'); - - const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; - expect(writtenSpec.abTests).toHaveLength(1); - expect(writtenSpec.abTests[0].name).toBe('MyTest'); - expect(writtenSpec.abTests[0].variants).toHaveLength(2); - expect(writtenSpec.abTests[0].variants[0].name).toBe('C'); - expect(writtenSpec.abTests[0].variants[0].weight).toBe(80); - expect(writtenSpec.abTests[0].variants[1].name).toBe('T1'); - expect(writtenSpec.abTests[0].variants[1].weight).toBe(20); - }); - - it('includes optional fields when provided', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject()); - mockWriteProjectSpec.mockResolvedValue(undefined); - - await primitive.add({ - ...validOptions, - description: 'Test description', - roleArn: 'arn:aws:iam::123:role/MyRole', - trafficHeaderName: 'X-AB-Route', - maxDurationDays: 30, - enableOnCreate: true, - }); - - const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; - const test = writtenSpec.abTests[0]; - expect(test.description).toBe('Test description'); - expect(test.roleArn).toBe('arn:aws:iam::123:role/MyRole'); - expect(test.trafficAllocationConfig).toEqual({ routeOnHeader: { headerName: 'X-AB-Route' } }); - expect(test.maxDurationDays).toBe(30); - expect(test.enableOnCreate).toBe(true); - }); - - it('omits optional fields when not provided', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject()); - mockWriteProjectSpec.mockResolvedValue(undefined); - - await primitive.add(validOptions); - - const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; - const test = writtenSpec.abTests[0]; - expect(test.description).toBeUndefined(); - expect(test.roleArn).toBeUndefined(); - expect(test.trafficAllocationConfig).toBeUndefined(); - expect(test.maxDurationDays).toBeUndefined(); - expect(test.enableOnCreate).toBeUndefined(); - }); - - it('returns error when AB test name already exists', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyTest' }])); - - const result = await primitive.add(validOptions); - - expect(result).toEqual( - expect.objectContaining({ - success: false, - error: expect.objectContaining({ message: expect.stringContaining('already exists') }), - }) - ); - }); - - it('returns error when readProjectSpec fails', async () => { - mockReadProjectSpec.mockRejectedValue(new Error('disk read error')); - - const result = await primitive.add(validOptions); - - expect(result).toEqual(expect.objectContaining({ success: false, error: new Error('disk read error') })); - }); - - it('returns error when writeProjectSpec fails', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject()); - mockWriteProjectSpec.mockRejectedValue(new Error('disk write error')); - - const result = await primitive.add(validOptions); - - expect(result).toEqual(expect.objectContaining({ success: false, error: new Error('disk write error') })); - }); - - it('returns error when variant weights do not sum to 100', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject()); - - const result = await primitive.add({ - ...validOptions, - controlWeight: 80, - treatmentWeight: 80, - }); - - expect(result.success).toBe(false); - }); - }); - - describe('remove', () => { - it('removes AB test from project spec', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'TestA' }, { name: 'TestB' }])); - mockWriteProjectSpec.mockResolvedValue(undefined); - - const result = await primitive.remove('TestA'); - - expect(result.success).toBe(true); - const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; - expect(writtenSpec.abTests).toHaveLength(1); - expect(writtenSpec.abTests[0].name).toBe('TestB'); - }); - - it('returns error when AB test not found', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject()); - - const result = await primitive.remove('NonExistent'); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.message).toContain('NonExistent'); - expect(result.error.message).toContain('not found'); - } - }); - - it('returns error when readProjectSpec fails', async () => { - mockReadProjectSpec.mockRejectedValue(new Error('io error')); - - const result = await primitive.remove('Whatever'); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.message).toBe('io error'); - } - }); - - it('cascade-deletes orphaned HTTP gateway when last referencing AB test is removed', async () => { - const project = makeProject([{ name: 'TestA', gatewayRef: '{{gateway:TestA-gw}}' }]); - project.httpGateways = [{ name: 'TestA-gw', runtimeRef: 'my-agent' }]; - mockReadProjectSpec.mockResolvedValue(project); - mockWriteProjectSpec.mockResolvedValue(undefined); - - const result = await primitive.remove('TestA'); - - expect(result.success).toBe(true); - const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; - expect(writtenSpec.abTests).toHaveLength(0); - // Gateway is retained by default — cascade-delete only happens with deleteGateway: true - expect(writtenSpec.httpGateways).toHaveLength(1); - }); - - it('retains HTTP gateway when another AB test still references it', async () => { - const project = makeProject([ - { name: 'TestA', gatewayRef: '{{gateway:shared-gw}}' }, - { name: 'TestB', gatewayRef: '{{gateway:shared-gw}}' }, - ]); - project.httpGateways = [{ name: 'shared-gw', runtimeRef: 'my-agent' }]; - mockReadProjectSpec.mockResolvedValue(project); - mockWriteProjectSpec.mockResolvedValue(undefined); - - const result = await primitive.remove('TestA'); - - expect(result.success).toBe(true); - const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; - expect(writtenSpec.abTests).toHaveLength(1); - expect(writtenSpec.httpGateways).toHaveLength(1); - expect(writtenSpec.httpGateways[0].name).toBe('shared-gw'); - }); - }); - - describe('previewRemove', () => { - it('returns preview with schema changes', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'TestA' }])); - - const preview = await primitive.previewRemove('TestA'); - - expect(preview.summary[0]).toContain('Removing AB test: TestA'); - expect(preview.schemaChanges).toHaveLength(1); - expect(preview.schemaChanges[0]!.file).toBe('agentcore/agentcore.json'); - expect((preview.schemaChanges[0]!.after as { abTests: unknown[] }).abTests).toHaveLength(0); - }); - - it('throws when AB test not found', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject()); - - await expect(primitive.previewRemove('Missing')).rejects.toThrow('not found'); - }); - }); - - describe('getRemovable', () => { - it('returns AB test names', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'A' }, { name: 'B' }])); - - const result = await primitive.getRemovable(); - - expect(result).toEqual([{ name: 'A' }, { name: 'B' }]); - }); - - it('returns empty array on error', async () => { - mockReadProjectSpec.mockRejectedValue(new Error('fail')); - - expect(await primitive.getRemovable()).toEqual([]); - }); - }); - - describe('getAllNames', () => { - it('returns AB test names as strings', async () => { - mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'X' }, { name: 'Y' }])); - - const result = await primitive.getAllNames(); - - expect(result).toEqual(['X', 'Y']); - }); - - it('returns empty array on error', async () => { - mockReadProjectSpec.mockRejectedValue(new Error('fail')); - - expect(await primitive.getAllNames()).toEqual([]); - }); - }); -}); diff --git a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts index 863bc780c..193a1c6b5 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -8,6 +8,7 @@ const defaultProject: AgentCoreProjectSpec = { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -15,7 +16,6 @@ const defaultProject: AgentCoreProjectSpec = { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], diff --git a/src/cli/primitives/__tests__/GatewayTargetPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayTargetPrimitive.test.ts new file mode 100644 index 000000000..c7108dff3 --- /dev/null +++ b/src/cli/primitives/__tests__/GatewayTargetPrimitive.test.ts @@ -0,0 +1,412 @@ +import type { AgentCoreProjectSpec } from '../../../schema'; +import { GatewayTargetPrimitive } from '../GatewayTargetPrimitive'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const defaultProject: AgentCoreProjectSpec = { + name: 'test', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + knowledgeBases: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [ + { + name: 'my-gateway', + targets: [], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + }, + ], + policyEngines: [], + configBundles: [], + abTests: [], + harnesses: [], + datasets: [], +}; + +const { mockConfigExists, mockReadProjectSpec, mockWriteProjectSpec } = vi.hoisted(() => ({ + mockConfigExists: vi.fn().mockReturnValue(true), + mockReadProjectSpec: vi.fn(), + mockWriteProjectSpec: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../lib', () => { + const MockConfigIO = vi.fn(function (this: Record) { + this.configExists = mockConfigExists; + this.readProjectSpec = mockReadProjectSpec; + this.writeProjectSpec = mockWriteProjectSpec; + }); + return { + ConfigIO: MockConfigIO, + findConfigRoot: vi.fn().mockReturnValue('/fake/root'), + requireConfigRoot: vi.fn().mockReturnValue('/fake/root'), + setEnvVar: vi.fn().mockResolvedValue(undefined), + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + APP_DIR: 'app', + MCP_APP_SUBDIR: 'mcp', + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, + ValidationError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ValidationError'; + } + }, + }; +}); + +/** Extract the written project spec targets for the gateway. */ +function getWrittenGatewayTargets() { + expect(mockWriteProjectSpec).toHaveBeenCalledTimes(1); + const spec = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + const gw = spec.agentCoreGateways[0]; + expect(gw).toBeDefined(); + return gw!.targets; +} + +describe('GatewayTargetPrimitive', () => { + let primitive: GatewayTargetPrimitive; + + beforeEach(() => { + vi.clearAllMocks(); + mockReadProjectSpec.mockImplementation(() => Promise.resolve(JSON.parse(JSON.stringify(defaultProject)))); + primitive = new GatewayTargetPrimitive(); + }); + + describe('createHttpRuntimeTarget', () => { + it('writes correct nested httpRuntime structure to agentcore.json', async () => { + await primitive.createHttpRuntimeTarget({ + name: 'my-http-target', + gateway: 'my-gateway', + runtime: 'my-agent', + }); + + const targets = getWrittenGatewayTargets(); + expect(targets).toHaveLength(1); + expect(targets[0]).toEqual({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent' }, + }); + }); + + it('includes runtimeEndpoint when endpoint is specified', async () => { + await primitive.createHttpRuntimeTarget({ + name: 'my-http-target', + gateway: 'my-gateway', + runtime: 'my-agent', + endpoint: 'LIVE', + }); + + const targets = getWrittenGatewayTargets(); + expect(targets[0]).toEqual({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent', runtimeEndpoint: 'LIVE' }, + }); + }); + + it('includes outboundAuth when OAUTH is specified', async () => { + await primitive.createHttpRuntimeTarget({ + name: 'my-http-target', + gateway: 'my-gateway', + runtime: 'my-agent', + outboundAuth: { type: 'OAUTH', credentialName: 'my-cred', scopes: ['read', 'write'] }, + }); + + const targets = getWrittenGatewayTargets(); + expect(targets[0]).toEqual({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent' }, + outboundAuth: { type: 'OAUTH', credentialName: 'my-cred', scopes: ['read', 'write'] }, + }); + }); + + it('omits outboundAuth when type is NONE', async () => { + await primitive.createHttpRuntimeTarget({ + name: 'my-http-target', + gateway: 'my-gateway', + runtime: 'my-agent', + outboundAuth: { type: 'NONE' }, + }); + + const targets = getWrittenGatewayTargets(); + expect(targets[0]!.outboundAuth).toBeUndefined(); + }); + + it('throws error for duplicate target name', async () => { + mockReadProjectSpec.mockImplementation(() => + Promise.resolve({ + ...JSON.parse(JSON.stringify(defaultProject)), + agentCoreGateways: [ + { + name: 'my-gateway', + targets: [{ name: 'existing-target', targetType: 'httpRuntime', httpRuntime: { runtime: 'x' } }], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + }, + ], + }) + ); + + await expect( + primitive.createHttpRuntimeTarget({ + name: 'existing-target', + gateway: 'my-gateway', + runtime: 'my-agent', + }) + ).rejects.toThrow(/already exists/); + }); + + it('throws error for missing gateway', async () => { + await expect( + primitive.createHttpRuntimeTarget({ + name: 'my-http-target', + gateway: 'non-existent-gateway', + runtime: 'my-agent', + }) + ).rejects.toThrow(/not found/); + }); + }); +}); + +// ============================================================================ +// Connector gateway-target tests — use spy-based mocks (different style from +// the hoisted vi.mock above). Both styles compose cleanly because the spies +// only attach to instances created inside makePrimitive(). +// ============================================================================ + +function emptyProject(): AgentCoreProjectSpec { + return { + version: '1.0', + name: 'TestProj', + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + policyEngines: [], + datasets: [], + agentCoreGateways: [ + { + name: 'main-gw', + targets: [], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + }, + ], + knowledgeBases: [], + } as unknown as AgentCoreProjectSpec; +} + +function makePrimitive(initial: AgentCoreProjectSpec) { + const primitive = new GatewayTargetPrimitive(); + let project = initial; + vi.spyOn( + primitive as unknown as { readProjectSpec: () => Promise }, + 'readProjectSpec' + ).mockImplementation(() => Promise.resolve(project)); + vi.spyOn( + primitive as unknown as { writeProjectSpec: (p: AgentCoreProjectSpec) => Promise }, + 'writeProjectSpec' + ).mockImplementation((p: AgentCoreProjectSpec) => { + project = p; + return Promise.resolve(); + }); + return { primitive, getProject: () => project }; +} + +describe('GatewayTargetPrimitive — createConnectorGatewayTarget', () => { + afterEach(() => vi.restoreAllMocks()); + + it('writes a single-KB Retrieve target for bedrock-knowledge-bases', async () => { + const { primitive, getProject } = makePrimitive(emptyProject()); + const result = await primitive.createConnectorGatewayTarget({ + targetType: 'connector', + name: 'product-docs', + gateway: 'main-gw', + connectorId: 'bedrock-knowledge-bases', + knowledgeBaseId: 'ABCDEFGHIJ', + }); + expect(result.toolName).toBe('product-docs'); + const targets = getProject().agentCoreGateways[0]?.targets ?? []; + const retrieve = targets.find(t => t.connectorId === 'bedrock-knowledge-bases'); + expect(retrieve?.connectorId).toBe('bedrock-knowledge-bases'); + expect(retrieve?.knowledgeBaseId).toBe('ABCDEFGHIJ'); + expect(retrieve?.knowledgeBaseIds).toBeUndefined(); + }); + + it('bedrock-knowledge-bases create also upserts a shared agentic-retrieve target', async () => { + const { primitive, getProject } = makePrimitive(emptyProject()); + await primitive.createConnectorGatewayTarget({ + targetType: 'connector', + name: 'product-docs', + gateway: 'main-gw', + connectorId: 'bedrock-knowledge-bases', + knowledgeBaseId: 'ABCDEFGHIJ', + }); + const targets = getProject().agentCoreGateways[0]?.targets ?? []; + expect(targets).toHaveLength(2); + const retrieve = targets.find(t => t.connectorId === 'bedrock-knowledge-bases'); + expect(retrieve?.name).toBe('product-docs'); + expect(retrieve?.knowledgeBaseId).toBe('ABCDEFGHIJ'); + const agentic = targets.find(t => t.connectorId === 'bedrock-agentic-retrieve'); + expect(agentic?.name).toBe('main-gw-agentic'); + expect(agentic?.knowledgeBaseIds).toEqual(['ABCDEFGHIJ']); + }); + + it('two bedrock-knowledge-bases creates share a single agentic target with both KBs', async () => { + const { primitive, getProject } = makePrimitive(emptyProject()); + await primitive.createConnectorGatewayTarget({ + targetType: 'connector', + name: 'docs-a', + gateway: 'main-gw', + connectorId: 'bedrock-knowledge-bases', + knowledgeBaseId: 'ABCDEFGHIJ', + }); + await primitive.createConnectorGatewayTarget({ + targetType: 'connector', + name: 'docs-b', + gateway: 'main-gw', + connectorId: 'bedrock-knowledge-bases', + knowledgeBaseId: 'KLMNOPQRST', + }); + const targets = getProject().agentCoreGateways[0]?.targets ?? []; + // Two Retrieve targets + one shared agentic target. + expect(targets).toHaveLength(3); + const agentics = targets.filter(t => t.connectorId === 'bedrock-agentic-retrieve'); + expect(agentics).toHaveLength(1); + expect(agentics[0]?.knowledgeBaseIds).toEqual(['ABCDEFGHIJ', 'KLMNOPQRST']); + }); + + it('appends to an existing agentic target created earlier (e.g. via the KB primitive)', async () => { + const initial = emptyProject(); + initial.agentCoreGateways[0]!.targets = [ + { + name: 'main-gw-agentic', + targetType: 'connector', + connectorId: 'bedrock-agentic-retrieve', + knowledgeBaseIds: ['existing-kb'], + } as unknown as AgentCoreProjectSpec['agentCoreGateways'][0]['targets'][0], + ]; + const { primitive, getProject } = makePrimitive(initial); + await primitive.createConnectorGatewayTarget({ + targetType: 'connector', + name: 'product-docs', + gateway: 'main-gw', + connectorId: 'bedrock-knowledge-bases', + knowledgeBaseId: 'ABCDEFGHIJ', + }); + const targets = getProject().agentCoreGateways[0]?.targets ?? []; + const agentics = targets.filter(t => t.connectorId === 'bedrock-agentic-retrieve'); + expect(agentics).toHaveLength(1); + expect(agentics[0]?.knowledgeBaseIds).toEqual(['existing-kb', 'ABCDEFGHIJ']); + expect(targets.find(t => t.connectorId === 'bedrock-knowledge-bases')?.name).toBe('product-docs'); + }); + + it('writes a fan-out agentic-retrieve target with knowledgeBaseIds[]', async () => { + const { primitive, getProject } = makePrimitive(emptyProject()); + await primitive.createConnectorGatewayTarget({ + targetType: 'connector', + name: 'agentic', + gateway: 'main-gw', + connectorId: 'bedrock-agentic-retrieve', + knowledgeBaseIds: ['ABCDEFGHIJ', 'KLMNOPQRST'], + }); + const target = getProject().agentCoreGateways[0]?.targets[0]; + expect(target?.connectorId).toBe('bedrock-agentic-retrieve'); + expect(target?.knowledgeBaseIds).toEqual(['ABCDEFGHIJ', 'KLMNOPQRST']); + expect(target?.knowledgeBaseId).toBeUndefined(); + }); + + it('rejects a second agentic-retrieve target on the same gateway', async () => { + const initial = emptyProject(); + initial.agentCoreGateways[0]!.targets = [ + { + name: 'main-gw-agentic', + targetType: 'connector', + connectorId: 'bedrock-agentic-retrieve', + knowledgeBaseIds: ['existing'], + } as unknown as AgentCoreProjectSpec['agentCoreGateways'][0]['targets'][0], + ]; + const { primitive } = makePrimitive(initial); + await expect( + primitive.createConnectorGatewayTarget({ + targetType: 'connector', + name: 'another-agentic', + gateway: 'main-gw', + connectorId: 'bedrock-agentic-retrieve', + knowledgeBaseIds: ['ABCDEFGHIJ'], + }) + ).rejects.toThrow(/already has a bedrock-agentic-retrieve target/); + }); +}); + +describe('GatewayTargetPrimitive — createWebSearchGatewayTarget', () => { + afterEach(() => vi.restoreAllMocks()); + + it('writes a webSearch target with no excludeDomains when omitted', async () => { + const { primitive, getProject } = makePrimitive(emptyProject()); + const result = await primitive.createWebSearchGatewayTarget({ + targetType: 'webSearch', + name: 'web-search', + gateway: 'main-gw', + }); + expect(result.toolName).toBe('web-search'); + const target = getProject().agentCoreGateways[0]?.targets[0]; + expect(target?.targetType).toBe('webSearch'); + expect(target?.name).toBe('web-search'); + expect(target?.excludeDomains).toBeUndefined(); + }); + + it('persists excludeDomains when provided', async () => { + const { primitive, getProject } = makePrimitive(emptyProject()); + await primitive.createWebSearchGatewayTarget({ + targetType: 'webSearch', + name: 'ws', + gateway: 'main-gw', + excludeDomains: ['internal.example.com', 'staging.example.com'], + }); + const target = getProject().agentCoreGateways[0]?.targets[0]; + expect(target?.excludeDomains).toEqual(['internal.example.com', 'staging.example.com']); + }); + + it('rejects a duplicate target name on the same gateway', async () => { + const { primitive } = makePrimitive(emptyProject()); + await primitive.createWebSearchGatewayTarget({ + targetType: 'webSearch', + name: 'ws', + gateway: 'main-gw', + }); + await expect( + primitive.createWebSearchGatewayTarget({ + targetType: 'webSearch', + name: 'ws', + gateway: 'main-gw', + }) + ).rejects.toThrow(/already exists/); + }); + + it('rejects a target attached to an unknown gateway', async () => { + const { primitive } = makePrimitive(emptyProject()); + await expect( + primitive.createWebSearchGatewayTarget({ + targetType: 'webSearch', + name: 'ws', + gateway: 'does-not-exist', + }) + ).rejects.toThrow(/not found/); + }); +}); diff --git a/src/cli/primitives/__tests__/HarnessPrimitive.remove.test.ts b/src/cli/primitives/__tests__/HarnessPrimitive.remove.test.ts new file mode 100644 index 000000000..a839b09c6 --- /dev/null +++ b/src/cli/primitives/__tests__/HarnessPrimitive.remove.test.ts @@ -0,0 +1,271 @@ +import { AgentCoreApiError } from '../../aws/api-client'; +import { HarnessPrimitive } from '../HarnessPrimitive'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); +const mockReadDeployedState = vi.fn(); +const mockWriteDeployedState = vi.fn(); +const mockDeleteHarness = vi.fn(); +const mockRm = vi.fn(); + +vi.mock('../../../lib', () => ({ + APP_DIR: 'app', + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + readDeployedState = mockReadDeployedState; + writeDeployedState = mockWriteDeployedState; + getPathResolver = () => ({ getHarnessDir: (name: string) => `/fake/root/app/${name}` }); + }, + findConfigRoot: () => '/fake/root', +})); + +vi.mock('../../aws/agentcore-harness', async importOriginal => { + // Keep the real isHarnessNotFoundError + AgentCoreApiError (the typed-error contract under test); + // only deleteHarness is stubbed. + const actual = await importOriginal(); + return { ...actual, deleteHarness: (...args: unknown[]) => mockDeleteHarness(...args) }; +}); + +vi.mock('fs/promises', () => ({ + rm: (...args: unknown[]) => mockRm(...args), + access: vi.fn(), + copyFile: vi.fn(), + mkdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), +})); + +const ARN = 'arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/h-legacy'; + +function project(harnessNames: string[] = [], memoryNames: string[] = []) { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: memoryNames.map(name => ({ name })), + harnesses: harnessNames.map(name => ({ name, path: `app/${name}` })), + }; +} + +function orphanState(name = 'legacy', target = 'default') { + return { + targets: { + [target]: { + resources: { + stackName: 'S', + harnesses: { + [name]: { harnessId: 'h-legacy', harnessArn: ARN, roleArn: 'arn:r', status: 'READY' }, + }, + }, + }, + }, + }; +} + +const primitive = new HarnessPrimitive(); + +describe('HarnessPrimitive.remove — orphan handling', () => { + afterEach(() => vi.clearAllMocks()); + + it('refuses to delete an orphan without an explicit choice (never auto-deletes)', async () => { + mockReadProjectSpec.mockResolvedValue(project(['legacy'], ['legacyMemory'])); + mockReadDeployedState.mockResolvedValue(orphanState()); + + const result = await primitive.remove('legacy'); + + expect(result.success).toBe(false); + if (!result.success) { + // Explicitly states nothing happened, then offers the two explicit choices. + expect(result.error.message).toContain('No changes were made'); + expect(result.error.message).toContain('was not deleted'); + expect(result.error.message).toContain('--keep'); + expect(result.error.message).toContain('--discard'); + } + expect(mockDeleteHarness).not.toHaveBeenCalled(); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + expect(mockWriteDeployedState).not.toHaveBeenCalled(); + }); + + it('delete-and-keep: deletes from AWS, clears the orphan record, KEEPS the agentcore.json entry', async () => { + mockReadProjectSpec.mockResolvedValue(project(['legacy'], ['legacyMemory'])); + mockReadDeployedState.mockResolvedValue(orphanState()); + mockDeleteHarness.mockResolvedValue({}); + + const result = await primitive.remove('legacy', { orphanAction: 'keep' }); + + expect(result.success).toBe(true); + expect(mockDeleteHarness).toHaveBeenCalledWith({ region: 'us-west-2', harnessId: 'h-legacy' }); + // Orphan record dropped from deployed-state... + const written = mockWriteDeployedState.mock.calls[0]![0]; + expect(written.targets.default.resources.harnesses.legacy).toBeUndefined(); + // ...but the spec entry is kept (no spec write) so the next deploy recreates it under CFN. + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + expect(mockRm).not.toHaveBeenCalled(); + }); + + it('delete-and-discard: deletes from AWS, clears the record, and removes the spec entry + memory + dir', async () => { + mockReadProjectSpec.mockResolvedValue(project(['legacy'], ['legacyMemory'])); + mockReadDeployedState.mockResolvedValue(orphanState()); + mockDeleteHarness.mockResolvedValue({}); + + const result = await primitive.remove('legacy', { orphanAction: 'discard' }); + + expect(result.success).toBe(true); + expect(mockDeleteHarness).toHaveBeenCalledWith({ region: 'us-west-2', harnessId: 'h-legacy' }); + const writtenState = mockWriteDeployedState.mock.calls[0]![0]; + expect(writtenState.targets.default.resources.harnesses.legacy).toBeUndefined(); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.harnesses.find((h: { name: string }) => h.name === 'legacy')).toBeUndefined(); + expect(writtenSpec.memories.find((m: { name: string }) => m.name === 'legacyMemory')).toBeUndefined(); + expect(mockRm).toHaveBeenCalledWith('/fake/root/app/legacy', expect.objectContaining({ recursive: true })); + }); + + it('treats a 404 from deleteHarness as already-deleted (success), still reconciling state', async () => { + mockReadProjectSpec.mockResolvedValue(project(['legacy'], ['legacyMemory'])); + mockReadDeployedState.mockResolvedValue(orphanState()); + // The control plane signals "already gone" with a typed 404 AgentCoreApiError — not a message + // substring — which isHarnessNotFoundError keys on. + mockDeleteHarness.mockRejectedValue(new AgentCoreApiError(404, 'harness not found')); + + const result = await primitive.remove('legacy', { orphanAction: 'keep' }); + + expect(result.success).toBe(true); + expect(mockWriteDeployedState).toHaveBeenCalled(); + }); + + it('aborts when deleteHarness fails with a non-404 "does not exist" message (no substring false-match)', async () => { + mockReadProjectSpec.mockResolvedValue(project(['legacy'], ['legacyMemory'])); + mockReadDeployedState.mockResolvedValue(orphanState()); + // A dependent-resource error that mentions "does not exist" must NOT be misread as harness-gone. + mockDeleteHarness.mockRejectedValue(new Error('IAM role for harness does not exist')); + + const result = await primitive.remove('legacy', { orphanAction: 'discard' }); + + expect(result.success).toBe(false); + expect(mockWriteDeployedState).not.toHaveBeenCalled(); + }); + + it('aborts and leaves local state unchanged when deleteHarness fails for a non-404 reason', async () => { + mockReadProjectSpec.mockResolvedValue(project(['legacy'], ['legacyMemory'])); + mockReadDeployedState.mockResolvedValue(orphanState()); + mockDeleteHarness.mockRejectedValue(new Error('AccessDeniedException: not authorized')); + + const result = await primitive.remove('legacy', { orphanAction: 'discard' }); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error.message).toContain('left unchanged'); + expect(mockWriteDeployedState).not.toHaveBeenCalled(); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); +}); + +describe('HarnessPrimitive.remove — non-orphan (CDK-managed) handling', () => { + afterEach(() => vi.clearAllMocks()); + + it('is a pure spec edit: removes entry + memory + dir, no AWS delete', async () => { + mockReadProjectSpec.mockResolvedValue(project(['h1'], ['h1Memory'])); + mockReadDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + stackName: 'S', + harnesses: { + h1: { + harnessId: 'h-h1', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:1:harness/h-h1', + roleArn: 'arn:r', + status: 'READY', + provisioner: 'cloudformation', + }, + }, + }, + }, + }, + }); + + const result = await primitive.remove('h1'); + + expect(result.success).toBe(true); + expect(mockDeleteHarness).not.toHaveBeenCalled(); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.harnesses.find((h: { name: string }) => h.name === 'h1')).toBeUndefined(); + expect(mockRm).toHaveBeenCalled(); + expect(mockWriteDeployedState).not.toHaveBeenCalled(); + }); + + it('managed harness (no ${name}Memory sibling): removes cleanly, leaves unrelated memories untouched', async () => { + // A managed-memory harness owns its memory internally — there is no `h1Memory` in project.memories, + // but an unrelated memory `otherMem` exists and must NOT be deleted. + mockReadProjectSpec.mockResolvedValue(project(['h1'], ['otherMem'])); + mockReadDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + stackName: 'S', + harnesses: { + h1: { + harnessId: 'h-h1', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:1:harness/h-h1', + roleArn: 'arn:r', + status: 'READY', + provisioner: 'cloudformation', + }, + }, + }, + }, + }, + }); + + const result = await primitive.remove('h1'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.harnesses.find((h: { name: string }) => h.name === 'h1')).toBeUndefined(); + // The unrelated memory survives — removal never deletes a memory the harness doesn't own. + expect(writtenSpec.memories.find((m: { name: string }) => m.name === 'otherMem')).toBeDefined(); + }); + + it('errors (does not silently no-op) when --keep/--discard is given for a CDK-managed harness', async () => { + mockReadProjectSpec.mockResolvedValue(project(['h1'], [])); + mockReadDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + stackName: 'S', + harnesses: { + h1: { + harnessId: 'h-h1', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:1:harness/h-h1', + roleArn: 'arn:r', + status: 'READY', + provisioner: 'cloudformation', + }, + }, + }, + }, + }, + }); + + const result = await primitive.remove('h1', { orphanAction: 'discard' }); + + // B27a: the orphan-only flags must not silently no-op on a CDK-managed harness — they error, + // the harness is NOT removed from the spec, and no AWS delete is issued. + expect(result.success).toBe(false); + if (!result.success) expect(result.error.message).toContain('only apply to a preview-build'); + expect(mockDeleteHarness).not.toHaveBeenCalled(); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('returns not-found when the harness is neither in spec nor an orphan', async () => { + mockReadProjectSpec.mockResolvedValue(project([], [])); + mockReadDeployedState.mockResolvedValue({ targets: {} }); + + const result = await primitive.remove('ghost'); + + expect(result.success).toBe(false); + if (!result.success) expect(result.error.message).toContain('not found'); + }); +}); diff --git a/src/cli/primitives/__tests__/KnowledgeBasePrimitive.test.ts b/src/cli/primitives/__tests__/KnowledgeBasePrimitive.test.ts new file mode 100644 index 000000000..d984087a7 --- /dev/null +++ b/src/cli/primitives/__tests__/KnowledgeBasePrimitive.test.ts @@ -0,0 +1,743 @@ +import type { AgentCoreProjectSpec } from '../../../schema'; +import { KnowledgeBasePrimitive } from '../KnowledgeBasePrimitive'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +function emptyProject(): AgentCoreProjectSpec { + return { + version: '1.0', + name: 'TestProj', + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + policyEngines: [], + datasets: [], + agentCoreGateways: [], + knowledgeBases: [], + } as unknown as AgentCoreProjectSpec; +} + +function makePrimitive(initial: AgentCoreProjectSpec) { + const primitive = new KnowledgeBasePrimitive(); + let project = initial; + vi.spyOn( + primitive as unknown as { readProjectSpec: () => Promise }, + 'readProjectSpec' + ).mockImplementation(() => Promise.resolve(project)); + vi.spyOn( + primitive as unknown as { writeProjectSpec: (p: AgentCoreProjectSpec) => Promise }, + 'writeProjectSpec' + ).mockImplementation((p: AgentCoreProjectSpec) => { + project = p; + return Promise.resolve(); + }); + return { primitive, getProject: () => project }; +} + +/** + * Like makePrimitive, but also points configIO.getConfigRoot() at a real + * temp `/agentcore` dir so materializeConnectorConfig can copy + * connector-config files into `/app//`. + */ +function makePrimitiveWithProjectDir(initial: AgentCoreProjectSpec) { + const projectRoot = mkdtempSync(join(tmpdir(), 'kb-prim-')); + const configRoot = join(projectRoot, 'agentcore'); + const base = makePrimitive(initial); + // configIO is a protected readonly field; spy on its getConfigRoot. + vi.spyOn( + (base.primitive as unknown as { configIO: { getConfigRoot: () => string } }).configIO, + 'getConfigRoot' + ).mockReturnValue(configRoot); + return { ...base, projectRoot, configRoot }; +} + +describe('add knowledge-base — non-S3 connectors', () => { + const tmpDirs: string[] = []; + afterEach(() => { + vi.restoreAllMocks(); + for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + function writeConfig(dir: string, name: string, body: Record): string { + const p = join(dir, name); + writeFileSync(p, JSON.stringify(body)); + return p; + } + + it('adds a WEB data source from a connector-config file and copies it under app//', async () => { + const { primitive, getProject, projectRoot } = makePrimitiveWithProjectDir(emptyProject()); + tmpDirs.push(projectRoot); + const webCfg = writeConfig(projectRoot, 'web.json', { + type: 'WEB', + connectionConfiguration: { authType: 'NO_AUTH' }, + }); + + const result = await primitive.add({ + name: 'web-docs', + dataSourceType: 'web-crawler', + connectorConfig: [webCfg], + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + const kb = getProject().knowledgeBases[0]; + expect(kb?.dataSources).toEqual([{ type: 'WEB', connectorConfigFile: 'app/web-docs/web.json' }]); + expect(existsSync(join(projectRoot, 'app', 'web-docs', 'web.json'))).toBe(true); + }); + + it('warns when an auth connector config has no secretArn but still succeeds', async () => { + const { primitive, getProject, projectRoot } = makePrimitiveWithProjectDir(emptyProject()); + tmpDirs.push(projectRoot); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const confCfg = writeConfig(projectRoot, 'confluence.json', { + type: 'CONFLUENCE', + connectionConfiguration: {}, + }); + + const result = await primitive.add({ + name: 'conf-docs', + dataSourceType: 'confluence', + connectorConfig: [confCfg], + }); + + expect(result.success).toBe(true); + expect(getProject().knowledgeBases).toHaveLength(1); + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls.flat().join(' ')).toMatch(/secretArn/i); + }); + + it('rejects --connector-config when data-source-type is s3', async () => { + const { primitive } = makePrimitive(emptyProject()); + const r = await primitive.add({ name: 'kb', dataSourceType: 's3', connectorConfig: ['/tmp/x.json'] }); + expect(r.success).toBe(false); + }); + + it('rejects --source when data-source-type is non-S3', async () => { + const { primitive } = makePrimitive(emptyProject()); + const r = await primitive.add({ name: 'kb', dataSourceType: 'web-crawler', source: ['s3://b/'] }); + expect(r.success).toBe(false); + }); + + it('errors when the connector config type disagrees with --data-source-type', async () => { + const { primitive, projectRoot } = makePrimitiveWithProjectDir(emptyProject()); + tmpDirs.push(projectRoot); + const cfg = writeConfig(projectRoot, 'mismatch.json', { + type: 'CONFLUENCE', + connectionConfiguration: {}, + }); + const r = await primitive.add({ + name: 'kb', + dataSourceType: 'web-crawler', + connectorConfig: [cfg], + }); + expect(r.success).toBe(false); + // No file should have been copied since validation failed. + expect(existsSync(join(projectRoot, 'app'))).toBe(false); + }); + + it('does not copy any file when the gateway is missing', async () => { + const { primitive, projectRoot } = makePrimitiveWithProjectDir(emptyProject()); + tmpDirs.push(projectRoot); + const cfg = writeConfig(projectRoot, 'web.json', { + type: 'WEB', + connectionConfiguration: { authType: 'NO_AUTH' }, + }); + const r = await primitive.add({ + name: 'kb', + dataSourceType: 'web-crawler', + connectorConfig: [cfg], + gateway: 'missing-gw', + }); + expect(r.success).toBe(false); + expect(existsSync(join(projectRoot, 'app'))).toBe(false); + }); + + it('rejects two connector configs with the same basename and copies nothing', async () => { + const { primitive, getProject, projectRoot } = makePrimitiveWithProjectDir(emptyProject()); + tmpDirs.push(projectRoot); + const dirA = join(projectRoot, 'a'); + const dirB = join(projectRoot, 'b'); + mkdirSync(dirA, { recursive: true }); + mkdirSync(dirB, { recursive: true }); + const aPath = writeConfig(dirA, 'web.json', { + type: 'WEB', + connectionConfiguration: { authType: 'NO_AUTH' }, + }); + const bPath = writeConfig(dirB, 'web.json', { + type: 'WEB', + connectionConfiguration: { authType: 'NO_AUTH' }, + }); + + const result = await primitive.add({ + name: 'kb', + dataSourceType: 'web-crawler', + connectorConfig: [aPath, bPath], + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/web\.json|collision|would both be stored/i); + // No copy happened, and the spec was not mutated. + expect(existsSync(join(projectRoot, 'app', 'kb', 'web.json'))).toBe(false); + expect(existsSync(join(projectRoot, 'app', 'kb'))).toBe(false); + expect(getProject().knowledgeBases).toHaveLength(0); + }); + + it('appends a different connector config to an existing connector KB and copies it', async () => { + const initial = emptyProject(); + initial.knowledgeBases = [ + { + type: 'AgentCoreKnowledgeBase', + name: 'conf-docs', + dataSources: [{ type: 'CONFLUENCE', connectorConfigFile: 'app/conf-docs/confluence.json' }], + }, + ] as unknown as AgentCoreProjectSpec['knowledgeBases']; + const { primitive, getProject, projectRoot } = makePrimitiveWithProjectDir(initial); + tmpDirs.push(projectRoot); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cfg2 = writeConfig(projectRoot, 'confluence2.json', { + type: 'CONFLUENCE', + connectionConfiguration: {}, + }); + + const result = await primitive.add({ + name: 'conf-docs', + dataSourceType: 'confluence', + connectorConfig: [cfg2], + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.appended).toBe(true); + expect(existsSync(join(projectRoot, 'app', 'conf-docs', 'confluence2.json'))).toBe(true); + expect(getProject().knowledgeBases[0]?.dataSources).toHaveLength(2); + // CONFLUENCE without secretArn warns. + expect(warnSpy.mock.calls.flat().join(' ')).toMatch(/secretArn/i); + }); + + it('suppresses the secretArn warning under --json', async () => { + const { primitive, projectRoot } = makePrimitiveWithProjectDir(emptyProject()); + tmpDirs.push(projectRoot); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cfg = writeConfig(projectRoot, 'confluence.json', { + type: 'CONFLUENCE', + connectionConfiguration: {}, + }); + + const result = await primitive.add({ + name: 'conf-docs', + dataSourceType: 'confluence', + connectorConfig: [cfg], + json: true, + }); + + expect(result.success).toBe(true); + expect(warnSpy.mock.calls.flat().join(' ')).not.toMatch(/secretArn/i); + }); + + it('keeps a connector config already inside app// in place', async () => { + const { primitive, getProject, projectRoot } = makePrimitiveWithProjectDir(emptyProject()); + tmpDirs.push(projectRoot); + const destDir = join(projectRoot, 'app', 'web-docs'); + rmSync(destDir, { recursive: true, force: true }); + // Place the config directly where materialize would copy it. + mkdirSync(destDir, { recursive: true }); + const inPlace = writeConfig(destDir, 'web.json', { + type: 'WEB', + connectionConfiguration: { authType: 'NO_AUTH' }, + }); + + const result = await primitive.add({ + name: 'web-docs', + dataSourceType: 'web-crawler', + connectorConfig: [inPlace], + }); + + expect(result.success).toBe(true); + const kb = getProject().knowledgeBases[0]; + expect(kb?.dataSources).toEqual([{ type: 'WEB', connectorConfigFile: 'app/web-docs/web.json' }]); + expect(readFileSync(inPlace, 'utf-8')).toContain('NO_AUTH'); + }); +}); + +describe('KnowledgeBasePrimitive — add (new KB)', () => { + afterEach(() => vi.restoreAllMocks()); + + it('creates a new KB entry when the name does not exist', async () => { + const { primitive, getProject } = makePrimitive(emptyProject()); + + const result = await primitive.add({ + name: 'product-docs', + source: ['s3://my-bucket/docs/'], + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.knowledgeBaseName).toBe('product-docs'); + expect(result.appended).toBe(false); + + const kbs = getProject().knowledgeBases; + expect(kbs).toHaveLength(1); + expect(kbs[0]?.name).toBe('product-docs'); + expect(kbs[0]?.dataSources).toEqual([{ type: 'S3', uri: 's3://my-bucket/docs/' }]); + }); + + it('accepts multiple --source flags on first invocation', async () => { + const { primitive, getProject } = makePrimitive(emptyProject()); + const result = await primitive.add({ + name: 'docs', + source: ['s3://my-bucket/a/', 's3://my-bucket/b/'], + }); + expect(result.success).toBe(true); + expect(getProject().knowledgeBases[0]?.dataSources).toHaveLength(2); + }); + + it('rejects when neither --source nor --connector-config is provided', async () => { + const { primitive } = makePrimitive(emptyProject()); + const result = await primitive.add({ name: 'empty' }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/at least one --source is required/i); + }); + + it('rejects --connector-config when the data source type defaults to s3', async () => { + const { primitive } = makePrimitive(emptyProject()); + const result = await primitive.add({ + name: 'kb', + connectorConfig: ['./confluence.json'], + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/only valid for non-S3/i); + }); + + it('rejects an invalid S3 URI', async () => { + const { primitive } = makePrimitive(emptyProject()); + const result = await primitive.add({ + name: 'kb', + source: ['https://example.com/docs'], + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/Invalid S3 URI/i); + }); + + it('errors when --gateway references a gateway not in agentCoreGateways[]', async () => { + const { primitive } = makePrimitive(emptyProject()); + const result = await primitive.add({ + name: 'docs', + source: ['s3://my-bucket/a/'], + gateway: 'missing-gw', + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/Gateway "missing-gw" not found/i); + }); + + it('with --gateway: emits a Retrieve target AND a gateway-scoped agentic-retrieve target', async () => { + const initial = emptyProject(); + initial.agentCoreGateways.push({ + name: 'main-gw', + targets: [], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + } as unknown as AgentCoreProjectSpec['agentCoreGateways'][0]); + const { primitive, getProject } = makePrimitive(initial); + + const result = await primitive.add({ + name: 'docs', + source: ['s3://my-bucket/a/'], + gateway: 'main-gw', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.gatewayWired).toBe('main-gw'); + + const project = getProject(); + expect(project.knowledgeBases[0]?.gateway).toBe('main-gw'); + const targets = project.agentCoreGateways[0]?.targets ?? []; + expect(targets).toHaveLength(2); + + const retrieve = targets.find(t => t.name === 'docs'); + expect(retrieve?.targetType).toBe('connector'); + expect(retrieve?.connectorId).toBe('bedrock-knowledge-bases'); + // The connector target stores the KB *name*; the L3 looks it up at synth. + expect(retrieve?.knowledgeBaseId).toBe('docs'); + + const agentic = targets.find(t => t.connectorId === 'bedrock-agentic-retrieve'); + expect(agentic?.name).toBe('main-gw-agentic'); + expect(agentic?.knowledgeBaseIds).toEqual(['docs']); + }); + + it('second KB on the same gateway appends to the existing agentic-retrieve target', async () => { + const initial = emptyProject(); + initial.agentCoreGateways.push({ + name: 'main-gw', + targets: [], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + } as unknown as AgentCoreProjectSpec['agentCoreGateways'][0]); + const { primitive, getProject } = makePrimitive(initial); + + await primitive.add({ name: 'docs', source: ['s3://my-bucket/a/'], gateway: 'main-gw' }); + await primitive.add({ name: 'hr', source: ['s3://my-bucket/b/'], gateway: 'main-gw' }); + + const targets = getProject().agentCoreGateways[0]?.targets ?? []; + // Two Retrieve targets + one agentic target. + expect(targets).toHaveLength(3); + expect(targets.filter(t => t.connectorId === 'bedrock-knowledge-bases').map(t => t.name)).toEqual(['docs', 'hr']); + const agentic = targets.find(t => t.connectorId === 'bedrock-agentic-retrieve'); + expect(agentic?.name).toBe('main-gw-agentic'); + expect(agentic?.knowledgeBaseIds).toEqual(['docs', 'hr']); + }); + + it('idempotent re-add: same KB twice does not duplicate it in the agentic-retrieve target', async () => { + const initial = emptyProject(); + initial.agentCoreGateways.push({ + name: 'main-gw', + targets: [], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + } as unknown as AgentCoreProjectSpec['agentCoreGateways'][0]); + const { primitive, getProject } = makePrimitive(initial); + + await primitive.add({ name: 'docs', source: ['s3://my-bucket/a/'], gateway: 'main-gw' }); + // Append a new data source on the same KB; --gateway is the same. + await primitive.add({ name: 'docs', source: ['s3://my-bucket/c/'], gateway: 'main-gw' }); + + const agentic = getProject().agentCoreGateways[0]?.targets.find(t => t.connectorId === 'bedrock-agentic-retrieve'); + expect(agentic?.knowledgeBaseIds).toEqual(['docs']); + }); + + it('rejects duplicate --source URIs within the same invocation', async () => { + const { primitive } = makePrimitive(emptyProject()); + const result = await primitive.add({ + name: 'docs', + source: ['s3://my-bucket/a/', 's3://my-bucket/a/'], + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/Duplicate data source in this invocation/i); + }); +}); + +describe('KnowledgeBasePrimitive — add (idempotent append)', () => { + afterEach(() => vi.restoreAllMocks()); + + function withExisting() { + const initial = emptyProject(); + initial.knowledgeBases = [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/a/' }], + }, + ]; + return makePrimitive(initial); + } + + it('appends a new data source to an existing KB', async () => { + const { primitive, getProject } = withExisting(); + const result = await primitive.add({ + name: 'docs', + source: ['s3://my-bucket/c/'], + }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.appended).toBe(true); + expect(result.newDataSources).toEqual(['s3://my-bucket/c/']); + + const kb = getProject().knowledgeBases[0]; + expect(kb?.dataSources).toHaveLength(2); + expect(kb?.dataSources.map(ds => (ds.type === 'S3' ? ds.uri : ds.connectorConfigFile))).toEqual([ + 's3://my-bucket/a/', + 's3://my-bucket/c/', + ]); + }); + + it('errors on duplicate URI', async () => { + const { primitive } = withExisting(); + const result = await primitive.add({ + name: 'docs', + source: ['s3://my-bucket/a/'], + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/already exists on knowledge-base/i); + }); + + it('errors when neither --source nor --connector-config given on re-invocation', async () => { + const { primitive } = withExisting(); + const result = await primitive.add({ name: 'docs' }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/at least one --source/i); + }); + + it('errors when description differs', async () => { + const initial = emptyProject(); + initial.knowledgeBases = [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + description: 'Original description', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/a/' }], + }, + ]; + const { primitive } = makePrimitive(initial); + const result = await primitive.add({ + name: 'docs', + source: ['s3://my-bucket/c/'], + description: 'Different description', + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/update operations are not supported/i); + }); + + it('preserves existing description if not provided', async () => { + const initial = emptyProject(); + initial.knowledgeBases = [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + description: 'Original', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/a/' }], + }, + ]; + const { primitive, getProject } = makePrimitive(initial); + const result = await primitive.add({ name: 'docs', source: ['s3://my-bucket/c/'] }); + expect(result.success).toBe(true); + expect(getProject().knowledgeBases[0]?.description).toBe('Original'); + }); + + it('treats empty-string description on append as a no-op', async () => { + const { primitive, getProject } = withExisting(); + const result = await primitive.add({ + name: 'docs', + source: ['s3://my-bucket/c/'], + description: '', + }); + expect(result.success).toBe(true); + expect(getProject().knowledgeBases[0]?.description).toBeUndefined(); + }); +}); + +describe('KnowledgeBasePrimitive — remove', () => { + afterEach(() => vi.restoreAllMocks()); + + function withTwoKbs() { + const initial = emptyProject(); + initial.knowledgeBases = [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/a/' }], + }, + { + type: 'AgentCoreKnowledgeBase', + name: 'compliance', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/c/' }], + }, + ]; + return makePrimitive(initial); + } + + it('removes the named KB and leaves others intact', async () => { + const { primitive, getProject } = withTwoKbs(); + const result = await primitive.remove('docs'); + expect(result.success).toBe(true); + expect(getProject().knowledgeBases.map(kb => kb.name)).toEqual(['compliance']); + }); + + it('returns failure when KB not found', async () => { + const { primitive } = withTwoKbs(); + const result = await primitive.remove('nonexistent'); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message).toMatch(/not found/i); + }); + + it('previewRemove returns a schema diff', async () => { + const { primitive } = withTwoKbs(); + const preview = await primitive.previewRemove('docs'); + expect(preview.summary[0]).toMatch(/Removing knowledge base: docs/); + expect(preview.schemaChanges).toHaveLength(1); + expect(preview.schemaChanges[0]?.file).toBe('agentcore/agentcore.json'); + }); + + it('previewRemove throws when KB not found', async () => { + const { primitive } = withTwoKbs(); + await expect(primitive.previewRemove('nonexistent')).rejects.toThrow(/not found/i); + }); + + it('getRemovable lists all KBs', async () => { + const { primitive } = withTwoKbs(); + const removables = await primitive.getRemovable(); + expect(removables.map(r => r.name)).toEqual(['docs', 'compliance']); + }); + + it('addScreen returns null (no TUI in Wave 1)', () => { + const primitive = new KnowledgeBasePrimitive(); + expect(primitive.addScreen()).toBeNull(); + }); + + it('cascade-prunes the removed KB out of the gateway agentic-retrieve target', async () => { + const initial = emptyProject(); + initial.knowledgeBases = [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/a/' }], + gateway: 'main-gw', + }, + { + type: 'AgentCoreKnowledgeBase', + name: 'hr', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/b/' }], + gateway: 'main-gw', + }, + ]; + initial.agentCoreGateways.push({ + name: 'main-gw', + targets: [ + { name: 'docs', targetType: 'connector', connectorId: 'bedrock-knowledge-bases', knowledgeBaseId: 'docs' }, + { name: 'hr', targetType: 'connector', connectorId: 'bedrock-knowledge-bases', knowledgeBaseId: 'hr' }, + { + name: 'main-gw-agentic', + targetType: 'connector', + connectorId: 'bedrock-agentic-retrieve', + knowledgeBaseIds: ['docs', 'hr'], + }, + ], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + } as unknown as AgentCoreProjectSpec['agentCoreGateways'][0]); + const { primitive, getProject } = makePrimitive(initial); + + const result = await primitive.remove('docs'); + expect(result.success).toBe(true); + + const targets = getProject().agentCoreGateways[0]?.targets ?? []; + // Per-KB Retrieve target gone; agentic target stays with hr only. + expect(targets.find(t => t.name === 'docs')).toBeUndefined(); + const agentic = targets.find(t => t.connectorId === 'bedrock-agentic-retrieve'); + expect(agentic).toBeDefined(); + expect(agentic?.knowledgeBaseIds).toEqual(['hr']); + }); + + it('removes the agentic-retrieve target entirely when the removed KB was its only entry', async () => { + const initial = emptyProject(); + initial.knowledgeBases = [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/a/' }], + gateway: 'main-gw', + }, + ]; + initial.agentCoreGateways.push({ + name: 'main-gw', + targets: [ + { name: 'docs', targetType: 'connector', connectorId: 'bedrock-knowledge-bases', knowledgeBaseId: 'docs' }, + { + name: 'main-gw-agentic', + targetType: 'connector', + connectorId: 'bedrock-agentic-retrieve', + knowledgeBaseIds: ['docs'], + }, + ], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + } as unknown as AgentCoreProjectSpec['agentCoreGateways'][0]); + const { primitive, getProject } = makePrimitive(initial); + + const result = await primitive.remove('docs'); + expect(result.success).toBe(true); + + const targets = getProject().agentCoreGateways[0]?.targets ?? []; + expect(targets).toHaveLength(0); + }); + + it('previewRemove summarizes the agentic-retrieve prune', async () => { + const initial = emptyProject(); + initial.knowledgeBases = [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/a/' }], + gateway: 'main-gw', + }, + { + type: 'AgentCoreKnowledgeBase', + name: 'hr', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/b/' }], + gateway: 'main-gw', + }, + ]; + initial.agentCoreGateways.push({ + name: 'main-gw', + targets: [ + { name: 'docs', targetType: 'connector', connectorId: 'bedrock-knowledge-bases', knowledgeBaseId: 'docs' }, + { name: 'hr', targetType: 'connector', connectorId: 'bedrock-knowledge-bases', knowledgeBaseId: 'hr' }, + { + name: 'main-gw-agentic', + targetType: 'connector', + connectorId: 'bedrock-agentic-retrieve', + knowledgeBaseIds: ['docs', 'hr'], + }, + ], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + } as unknown as AgentCoreProjectSpec['agentCoreGateways'][0]); + const { primitive } = makePrimitive(initial); + + const preview = await primitive.previewRemove('docs'); + const lines = preview.summary.join('\n'); + expect(lines).toMatch(/main-gw.*agentic-retrieve target 'main-gw-agentic' will lose KB 'docs'/); + }); + + it('previewRemove notes when the agentic-retrieve target itself will be removed', async () => { + const initial = emptyProject(); + initial.knowledgeBases = [ + { + type: 'AgentCoreKnowledgeBase', + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/a/' }], + gateway: 'main-gw', + }, + ]; + initial.agentCoreGateways.push({ + name: 'main-gw', + targets: [ + { name: 'docs', targetType: 'connector', connectorId: 'bedrock-knowledge-bases', knowledgeBaseId: 'docs' }, + { + name: 'main-gw-agentic', + targetType: 'connector', + connectorId: 'bedrock-agentic-retrieve', + knowledgeBaseIds: ['docs'], + }, + ], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + } as unknown as AgentCoreProjectSpec['agentCoreGateways'][0]); + const { primitive } = makePrimitive(initial); + + const preview = await primitive.previewRemove('docs'); + const lines = preview.summary.join('\n'); + expect(lines).toMatch(/main-gw.*agentic-retrieve target 'main-gw-agentic' will be removed \(was the last KB\)/); + }); +}); diff --git a/src/cli/primitives/__tests__/OnlineInsightsPrimitive.test.ts b/src/cli/primitives/__tests__/OnlineInsightsPrimitive.test.ts new file mode 100644 index 000000000..0b0068f4d --- /dev/null +++ b/src/cli/primitives/__tests__/OnlineInsightsPrimitive.test.ts @@ -0,0 +1,36 @@ +import { validateInsightIds } from '../OnlineInsightsPrimitive.js'; +import { describe, expect, it } from 'vitest'; + +describe('validateInsightIds', () => { + it('accepts Builtin.Insight.* identifiers', () => { + expect(() => validateInsightIds(['Builtin.Insight.FailureAnalysis'])).not.toThrow(); + }); + + it('accepts multiple valid identifiers', () => { + expect(() => validateInsightIds(['Builtin.Insight.FailureAnalysis', 'Builtin.Insight.UserIntent'])).not.toThrow(); + }); + + it('accepts full ARN identifiers', () => { + expect(() => + validateInsightIds(['arn:aws:bedrock-agentcore:us-east-1::evaluator/Builtin.Insight.FailureAnalysis']) + ).not.toThrow(); + }); + + it('rejects identifiers without Builtin.Insight.* prefix or ARN', () => { + expect(() => validateInsightIds(['InvalidString'])).toThrow('Must be a Builtin.Insight.* identifier'); + }); + + it('rejects Builtin.Helpfulness (evaluator prefix, not insight)', () => { + expect(() => validateInsightIds(['Builtin.Helpfulness'])).toThrow('Must be a Builtin.Insight.* identifier'); + }); + + it('rejects empty string', () => { + expect(() => validateInsightIds([''])).toThrow('Must be a Builtin.Insight.* identifier'); + }); + + it('rejects when any item in the array is invalid', () => { + expect(() => validateInsightIds(['Builtin.Insight.FailureAnalysis', 'bad'])).toThrow( + 'Must be a Builtin.Insight.* identifier' + ); + }); +}); diff --git a/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts b/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts index 3fc1136d3..48189aa6f 100644 --- a/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts +++ b/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts @@ -41,6 +41,7 @@ function makeProject(overrides: Partial = {}): AgentCorePr managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -50,6 +51,7 @@ function makeProject(overrides: Partial = {}): AgentCorePr abTests: [], httpGateways: [], harnesses: [], + datasets: [], payments: [], ...overrides, }; diff --git a/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts b/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts index 05f701b60..7bc1d47c5 100644 --- a/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts +++ b/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts @@ -27,6 +27,7 @@ function makeProject(overrides: Partial = {}): AgentCorePr managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -36,6 +37,7 @@ function makeProject(overrides: Partial = {}): AgentCorePr abTests: [], httpGateways: [], harnesses: [], + datasets: [], payments: [], ...overrides, }; diff --git a/src/cli/primitives/__tests__/PolicyPrimitive.test.ts b/src/cli/primitives/__tests__/PolicyPrimitive.test.ts new file mode 100644 index 000000000..2d95c7533 --- /dev/null +++ b/src/cli/primitives/__tests__/PolicyPrimitive.test.ts @@ -0,0 +1,111 @@ +import type { AgentCoreProjectSpec, Policy, PolicyEngine } from '../../../schema'; +import { PolicyPrimitive } from '../PolicyPrimitive'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const engine: PolicyEngine = { name: 'eng', policies: [] }; + +const defaultProject: AgentCoreProjectSpec = { + name: 'test', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + knowledgeBases: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [engine], + configBundles: [], + abTests: [], + harnesses: [], + datasets: [], + payments: [], +}; + +const { mockConfigExists, mockReadProjectSpec, mockWriteProjectSpec } = vi.hoisted(() => ({ + mockConfigExists: vi.fn().mockReturnValue(true), + mockReadProjectSpec: vi.fn(), + mockWriteProjectSpec: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../lib', () => { + const MockConfigIO = vi.fn(function (this: Record) { + this.configExists = mockConfigExists; + this.readProjectSpec = mockReadProjectSpec; + this.writeProjectSpec = mockWriteProjectSpec; + }); + return { + ConfigIO: MockConfigIO, + findConfigRoot: vi.fn().mockReturnValue('/fake/root'), + setEnvVar: vi.fn().mockResolvedValue(undefined), + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ValidationError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ValidationError'; + } + }, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, + }; +}); + +/** Extract the first policy written to the engine on writeProjectSpec. */ +function getWrittenPolicy(): Policy { + expect(mockWriteProjectSpec).toHaveBeenCalledTimes(1); + const spec = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + const policy = spec.policyEngines[0]?.policies[0]; + expect(policy).toBeDefined(); + return policy!; +} + +describe('PolicyPrimitive — enforcementMode', () => { + let primitive: PolicyPrimitive; + + beforeEach(() => { + vi.clearAllMocks(); + // Fresh engine each run so policies don't accumulate across tests + mockReadProjectSpec.mockImplementation(() => + Promise.resolve({ ...defaultProject, policyEngines: [{ name: 'eng', policies: [] }] }) + ); + primitive = new PolicyPrimitive(); + }); + + it('persists enforcementMode LOG_ONLY when provided', async () => { + const result = await primitive.add({ + name: 'shadow', + engine: 'eng', + statement: 'forbid(principal, action, resource is AgentCore::Gateway);', + enforcementMode: 'LOG_ONLY', + }); + expect(result.success).toBe(true); + expect(getWrittenPolicy().enforcementMode).toBe('LOG_ONLY'); + }); + + it('persists enforcementMode ACTIVE when provided', async () => { + const result = await primitive.add({ + name: 'active', + engine: 'eng', + statement: 'forbid(principal, action, resource is AgentCore::Gateway);', + enforcementMode: 'ACTIVE', + }); + expect(result.success).toBe(true); + expect(getWrittenPolicy().enforcementMode).toBe('ACTIVE'); + }); + + it('defaults enforcementMode to ACTIVE when omitted', async () => { + const result = await primitive.add({ + name: 'defaulted', + engine: 'eng', + statement: 'forbid(principal, action, resource is AgentCore::Gateway);', + }); + expect(result.success).toBe(true); + expect(getWrittenPolicy().enforcementMode).toBe('ACTIVE'); + }); +}); diff --git a/src/cli/primitives/__tests__/auth-utils.test.ts b/src/cli/primitives/__tests__/auth-utils.test.ts index 3eac9b61f..10c700961 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -88,6 +88,7 @@ describe('createManagedOAuthCredential', () => { managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -95,7 +96,6 @@ describe('createManagedOAuthCredential', () => { policyEngines: [], configBundles: [], abTests: [], - httpGateways: [], harnesses: [], datasets: [], payments: [], diff --git a/src/cli/primitives/__tests__/wirePaymentCapability.test.ts b/src/cli/primitives/__tests__/wirePaymentCapability.test.ts index 2aae99017..d00fbf2fa 100644 --- a/src/cli/primitives/__tests__/wirePaymentCapability.test.ts +++ b/src/cli/primitives/__tests__/wirePaymentCapability.test.ts @@ -83,6 +83,7 @@ function makeProject(codeLocation: string, runtimeOverrides: Record[] = [ credentialPrimitive, evaluatorPrimitive, onlineEvalConfigPrimitive, + onlineInsightsPrimitive, gatewayPrimitive, gatewayTargetPrimitive, + knowledgeBasePrimitive, policyEnginePrimitive, policyPrimitive, configBundlePrimitive, - abTestPrimitive, runtimeEndpointPrimitive, paymentManagerPrimitive, paymentConnectorPrimitive, diff --git a/src/cli/project.ts b/src/cli/project.ts index fa1654b43..3399042ab 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -13,6 +13,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS managedBy: 'CDK' as const, runtimes: [], memories: [], + knowledgeBases: [], credentials: [], evaluators: [], onlineEvalConfigs: [], @@ -21,7 +22,6 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS harnesses: [], configBundles: [], abTests: [], - httpGateways: [], datasets: [], payments: [], tags: { diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index 5ddea55fe..564709f2d 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -18,6 +18,7 @@ import { FilterType, GatewayTargetHost, GatewayTargetType, + JobType, MemoryType, Mode, ModelProvider, @@ -28,6 +29,7 @@ import { PolicyValidationMode, RefType, ResourceType, + SkillSourceType, UiMode, safeSchema, } from './common-shapes.js'; @@ -92,11 +94,20 @@ const AddGatewayTargetAttrs = safeSchema({ const AddPolicyEngineAttrs = safeSchema({ attach_gateway_count: Count, attach_mode: AttachMode }); +const AddKnowledgeBaseAttrs = safeSchema({ + data_source_count: Count, + has_description: z.boolean(), + has_gateway: z.boolean(), + is_append: z.boolean(), +}); + const AddPolicyAttrs = safeSchema({ policy_attr_source_type: PolicyAttrSourceType, policy_validation_mode: PolicyValidationMode, }); +const AddSkillAttrs = safeSchema({ skill_source_type: SkillSourceType }); + const DeployAttrs = safeSchema({ runtime_count: Count, harness_count: Count, @@ -155,8 +166,25 @@ const RunEvalAttrs = safeSchema({ has_expected_response: z.boolean(), }); +const RunIngestAttrs = safeSchema({ + data_source_count: Count, +}); + const FetchAccessAttrs = safeSchema({ resource_type: ResourceType }); +/** + * Async job commands (recommendation + batch-evaluation), keyed by verb with job_type to disambiguate. + * safeSchema only permits required enum/boolean/number/literal fields, so per-type detail enums + * (recommendation_kind, batch_eval_source, …) are recorded via the shared ATTRIBUTES set on the + * recorder when present rather than as required fields here. + */ +const RunJobAttrs = safeSchema({ + job_type: JobType, + has_wait: z.boolean(), +}); + +const JobTypeOnlyAttrs = safeSchema({ job_type: JobType }); + const UpdateAttrs = safeSchema({ is_dry_run: z.boolean() }); const FeedbackAttrs = safeSchema({ @@ -166,6 +194,18 @@ const FeedbackAttrs = safeSchema({ const PauseResumeOnlineEvalAttrs = safeSchema({ ref_type: RefType }); +const AddOnlineInsightsAttrs = safeSchema({ insights_count: Count, enable_on_create: z.boolean() }); + +const ExportHarnessAttrs = safeSchema({ + build_type: BuildType, + model_provider: ModelProvider, + has_memory: z.boolean(), + has_gateway: z.boolean(), + has_container: z.boolean(), + has_execution_limits: z.boolean(), + notes_count: Count, +}); + const NoAttrs = safeSchema({}); /* @@ -180,13 +220,17 @@ export const COMMAND_SCHEMAS = { 'add.credential': AddCredentialAttrs, 'add.evaluator': AddEvaluatorAttrs, 'add.online-eval': AddOnlineEvalAttrs, + 'add.online-insights': AddOnlineInsightsAttrs, 'add.gateway': AddGatewayAttrs, 'add.gateway-target': AddGatewayTargetAttrs, + 'add.web-search': NoAttrs, 'add.policy-engine': AddPolicyEngineAttrs, 'add.policy': AddPolicyAttrs, 'add.runtime-endpoint': NoAttrs, + 'add.knowledge-base': AddKnowledgeBaseAttrs, 'add.payment-manager': NoAttrs, 'add.payment-connector': NoAttrs, + 'add.skill': AddSkillAttrs, deploy: DeployAttrs, // dev / invoke / exec @@ -199,11 +243,22 @@ export const COMMAND_SCHEMAS = { logs: LogsAttrs, 'logs.evals': LogsEvalsAttrs, 'run.eval': RunEvalAttrs, + 'run.job': RunJobAttrs, + 'job.history': JobTypeOnlyAttrs, + 'job.get': JobTypeOnlyAttrs, + 'archive.job': JobTypeOnlyAttrs, + 'stop.job': JobTypeOnlyAttrs, + 'run.ingest': RunIngestAttrs, + 'pause.job': JobTypeOnlyAttrs, + 'resume.job': JobTypeOnlyAttrs, + 'promote.job': JobTypeOnlyAttrs, 'fetch.access': FetchAccessAttrs, feedback: FeedbackAttrs, update: UpdateAttrs, 'pause.online-eval': PauseResumeOnlineEvalAttrs, 'resume.online-eval': PauseResumeOnlineEvalAttrs, + 'pause.online-insights': PauseResumeOnlineEvalAttrs, + 'resume.online-insights': PauseResumeOnlineEvalAttrs, 'traces.list': NoAttrs, 'traces.get': NoAttrs, 'evals.history': NoAttrs, @@ -225,21 +280,25 @@ export const COMMAND_SCHEMAS = { 'remove.credential': NoAttrs, 'remove.evaluator': NoAttrs, 'remove.online-eval': NoAttrs, + 'remove.online-insights': NoAttrs, 'remove.gateway': NoAttrs, 'remove.gateway-target': NoAttrs, + 'remove.web-search': NoAttrs, 'remove.policy-engine': NoAttrs, 'remove.policy': NoAttrs, 'remove.runtime-endpoint': NoAttrs, 'remove.config-bundle': NoAttrs, - 'remove.ab-test': NoAttrs, + 'remove.knowledge-base': NoAttrs, 'dataset.download': NoAttrs, 'dataset.publish-version': NoAttrs, 'dataset.remove-version': NoAttrs, 'remove.payment-manager': NoAttrs, 'remove.payment-connector': NoAttrs, + 'remove.skill': NoAttrs, 'telemetry.disable': NoAttrs, 'telemetry.enable': NoAttrs, 'telemetry.status': NoAttrs, + 'export.harness': ExportHarnessAttrs, } as const satisfies Record>; // --------------------------------------------------------------------------- diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index a2389252b..5ffa63580 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -35,6 +35,7 @@ export const AuthType = z.enum(['sigv4', 'bearer_token']); export const AuthorizerType = z.enum(['aws_iam', 'custom_jwt', 'none']); export const BuildType = z.enum(['codezip', 'container']); export const CredentialType = z.enum(['api-key', 'oauth']); +export const SkillSourceType = z.enum(['path', 's3', 'git', 'aws_skills']); export const EvaluatorType = z.enum(['llm-as-a-judge', 'code-based']); export const ExitReason = z.enum(['success', 'failure']); export const FilterState = z.enum(['deployed', 'local-only', 'pending-removal', 'none']); @@ -49,7 +50,6 @@ export const FilterType = z.enum([ 'policy-engine', 'policy', 'config-bundle', - 'ab-test', 'dataset', 'harness', 'none', @@ -63,6 +63,9 @@ export const GatewayTargetType = z.enum([ 'open-api-schema', 'smithy-model', 'lambda-function-arn', + 'http-runtime', + 'passthrough', + 'web-search', 'unknown', ]); @@ -73,23 +76,32 @@ export const GATEWAY_TARGET_TYPE_MAP: Record { expect(mockCopyAndRenderDir).toHaveBeenCalledWith( join(tmpDir, 'python', 'http', 'strands', 'base'), '/output/app/MyAgent', - expect.objectContaining({ projectName: 'MyAgent', Name: 'MyAgent', hasMcp: false }) + expect.objectContaining({ projectName: 'MyAgent', Name: 'MyAgent' }) ); }); diff --git a/src/cli/templates/render.ts b/src/cli/templates/render.ts index 6f6aaff3d..3e4910c08 100644 --- a/src/cli/templates/render.ts +++ b/src/cli/templates/render.ts @@ -19,6 +19,34 @@ Handlebars.registerHelper('pathSlug', (str: string) => { .replace(/_+/g, '_') .toLowerCase(); }); +// Emits a value as JSON-safe Python literal (string or dict). +// Wraps result in Handlebars.SafeString to prevent double-escaping. +Handlebars.registerHelper('safeJson', (value: unknown) => { + return new Handlebars.SafeString(JSON.stringify(value)); +}); +// Emits a value as a Python string literal containing its JSON text, for use with json.loads(). +// Unlike safeJson, this is safe for arbitrary objects: JSON booleans/null inside the value stay +// inside the string (true/false/null) and are parsed by json.loads at runtime, rather than being +// inlined as bare Python tokens (which would be NameErrors). Double-encoding guarantees a valid +// Python string literal because JSON's escape set is a subset of Python's. +Handlebars.registerHelper('pyJsonStr', (value: unknown) => { + return new Handlebars.SafeString(JSON.stringify(JSON.stringify(value))); +}); +// Escapes triple-double-quotes so the value is safe to embed in a Python """...""" string. +Handlebars.registerHelper('escapePyStr', (value: unknown) => { + const s = typeof value === 'string' ? value : ''; + return new Handlebars.SafeString(s.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"')); +}); +Handlebars.registerHelper('some', (array: unknown[], key: string) => { + if (!Array.isArray(array)) return false; + return array.some( + item => item !== null && typeof item === 'object' && key in item && !!(item as Record)[key] + ); +}); +Handlebars.registerHelper('or', (...args: unknown[]) => { + // Last arg is the Handlebars options object — exclude it + return args.slice(0, -1).some(Boolean); +}); /** * Renames template files to their actual names. diff --git a/src/cli/templates/types.ts b/src/cli/templates/types.ts index 1c596226a..ec89bc103 100644 --- a/src/cli/templates/types.ts +++ b/src/cli/templates/types.ts @@ -38,6 +38,8 @@ export interface GatewayProviderRenderConfig { discoveryUrl?: string; /** Space-separated scopes for token request (CUSTOM_JWT only) */ scopes?: string; + /** Hardcoded URL for external gateways not found in deployed-state.json */ + hardcodedUrl?: string; } /** @@ -81,4 +83,59 @@ export interface AgentRenderConfig { enableOtel?: boolean; /** Whether a config bundle is wired into the agent template */ hasConfigBundle?: boolean; + + // ── Export-only fields (set by harness-mapper, consumed by export templates) ── + + /** Execution limits from harness — triggers execution-limits capability */ + maxIterations?: number; + maxTokens?: number; + timeoutSeconds?: number; + + /** Truncation strategy from harness. 'none' renders no truncation block (truncation disabled). */ + truncationStrategy?: 'sliding_window' | 'summarization' | 'none'; + truncationConfig?: Record; + + /** Inline function tool definitions — one PythonAgentTool generated per entry */ + inlineFunctionTools?: { name: string; description: string; inputSchema: Record }[]; + + /** Remote MCP tool definitions (non-gateway) */ + remoteMcpTools?: { + name: string; + url: string; + headerCredentials?: { headerKey: string; credentialName: string; envVarName: string }[]; + }[]; + + /** Skill paths for AgentSkills plugin */ + pathSkills?: string[]; + s3Skills?: string[]; + gitSkills?: { url: string; path?: string; credentialArn?: string; username?: string }[]; + /** True when any skills exist (path, s3, or git) — enables AgentSkills plugin */ + hasSkillsFetcher?: boolean; + /** True when s3 or git skills exist — enables fetcher imports */ + hasFetchedSkills?: boolean; + + /** True when agentcore_browser tool is present and allowed */ + hasBrowser?: boolean; + /** Custom browser identifier (resource ID extracted from browserArn) */ + browserIdentifier?: string; + /** True when agentcore_code_interpreter tool is present and allowed */ + hasCodeInterpreter?: boolean; + /** Custom code interpreter identifier (resource ID extracted from codeInterpreterArn) */ + codeInterpreterIdentifier?: string; + /** True when the builtin shell tool is enabled (export harness only) */ + hasShell?: boolean; + /** True when the builtin file_operations tool is enabled (export harness only) */ + hasFileOperations?: boolean; + /** True when any execution limit is configured */ + hasExecutionLimits?: boolean; + + /** Model ID to use in load.py (export path only — overrides the provider-specific template default) */ + modelId?: string; + + /** True when generating from a harness export (suppresses placeholder tools) */ + isExportHarness?: boolean; + /** System prompt text written verbatim into main.py (export path) */ + systemPromptText?: string; + /** Default actor ID for memory session manager */ + actorId?: string; } diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index f7c578aa6..81795d16d 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -4,7 +4,6 @@ import { LayoutProvider } from './context'; import { CLI_ONLY_EXAMPLES } from './copy'; import { setExitAction } from './exit-action'; import { MissingProjectMessage, WrongDirectoryMessage, getProjectRootMismatch, projectExists } from './guards'; -import { ABTestPickerScreen } from './screens/ab-test'; import { AddFlow } from './screens/add/AddFlow'; import { CliOnlyScreen } from './screens/cli-only'; import { ConfigBundleFlow } from './screens/config-bundle-hub'; @@ -12,19 +11,24 @@ import { CreateScreen } from './screens/create'; import { DatasetFlow } from './screens/dataset-hub'; import { DeployScreen } from './screens/deploy/DeployScreen'; import { EvalHubScreen, EvalScreen } from './screens/eval'; +import { ExportHarnessFlow } from './screens/export'; import { FetchAccessScreen } from './screens/fetch-access'; import { HelpScreen, HomeScreen } from './screens/home'; import { ImportFlow } from './screens/import'; +import { InsightsJobsScreen } from './screens/insights-jobs'; import { InvokeScreen } from './screens/invoke'; import { LogsScreen } from './screens/logs'; import { OnlineEvalDashboard } from './screens/online-eval'; import { PackageScreen } from './screens/package'; import { RecommendationFlow, RecommendationHistoryScreen, RecommendationsHubScreen } from './screens/recommendation'; import { RemoveFlow } from './screens/remove'; -import { BatchEvalHistoryScreen, RunBatchEvalFlow, RunEvalFlow, RunScreen } from './screens/run-eval'; +import { ABTestJobsHistoryScreen, RunABTestFlow } from './screens/run-ab-test'; +import { BatchEvalHistoryScreen, RunBatchEvalFlow, RunEvalFlow, RunIngestFlow, RunScreen } from './screens/run-eval'; +import { RunInsightsFlow } from './screens/run-insights'; import { StatusScreen } from './screens/status/StatusScreen'; import { UpdateScreen } from './screens/update'; import { ValidateScreen } from './screens/validate'; +import { ViewTypePickerScreen } from './screens/view'; import { getCommandsForUI } from './utils/commands'; import { useApp } from 'ink'; import React, { useState } from 'react'; @@ -55,10 +59,16 @@ type Route = | { name: 'run' } | { name: 'run-eval'; from?: 'run' | 'evals' } | { name: 'run-batch-eval'; from?: 'run' | 'evals' } + | { name: 'run-ingest'; from?: 'run' } | { name: 'batch-eval-history' } + | { name: 'run-insights'; from?: 'run' | 'evals' } + | { name: 'insights-jobs' } | { name: 'recommendations-hub' } | { name: 'recommend'; from?: 'recommendations-hub' | 'run' } | { name: 'recommendation-history' } + | { name: 'run-ab-test'; from?: 'run' } + | { name: 'ab-test-jobs' } + | { name: 'view' } | { name: 'evals' } | { name: 'eval-runs' } | { name: 'online-evals' } @@ -69,7 +79,7 @@ type Route = | { name: 'config-bundle' } | { name: 'dataset' } | { name: 'import' } - | { name: 'ab-test' } + | { name: 'export-harness' } | { name: 'cli-only'; commandId: string }; // Commands that don't require being at the project root @@ -163,8 +173,8 @@ function AppContent({ setRoute({ name: 'evals' }); } else if (id === 'fetch') { setRoute({ name: 'fetch-access' }); - } else if (id === 'recommendations') { - setRoute({ name: 'recommendations-hub' }); + } else if (id === 'view') { + setRoute({ name: 'view' }); } else if (id === 'validate') { setRoute({ name: 'validate' }); } else if (id === 'package') { @@ -177,12 +187,18 @@ function AppContent({ setRoute({ name: 'import' }); } else if (id === 'update') { setRoute({ name: 'update' }); + } else if (id === 'batch-evaluations') { + setRoute({ name: 'batch-eval-history' }); } else if (id === 'config-bundle') { setRoute({ name: 'config-bundle' }); } else if (id === 'dataset') { setRoute({ name: 'dataset' }); - } else if (id === 'ab-test') { - setRoute({ name: 'ab-test' }); + } else if (id === 'export') { + if (!projectExists() && route.name === 'help') { + setHelpNotice(); + return; + } + setRoute({ name: 'export-harness' }); } }; @@ -303,8 +319,21 @@ function AppContent({ setRoute({ name: 'run-eval', from: 'run' })} onRunBatchEval={() => setRoute({ name: 'run-batch-eval', from: 'run' })} + onRunInsights={() => setRoute({ name: 'run-insights', from: 'run' })} onRunRecommendation={() => setRoute({ name: 'recommend', from: 'run' })} + onRunIngest={() => setRoute({ name: 'run-ingest', from: 'run' })} + onRunABTest={() => setRoute({ name: 'run-ab-test', from: 'run' })} + onExit={handleBack} + /> + ); + } + + if (route.name === 'run-insights') { + return ( + setRoute({ name: route.from ?? 'run' } as Route)} + onViewJobs={() => setRoute({ name: 'insights-jobs' })} /> ); } @@ -317,6 +346,8 @@ function AppContent({ if (view === 'runs') setRoute({ name: 'eval-runs' }); if (view === 'run-batch-eval') setRoute({ name: 'run-batch-eval', from: 'evals' }); if (view === 'batch-eval-history') setRoute({ name: 'batch-eval-history' }); + if (view === 'run-insights') setRoute({ name: 'run-insights', from: 'evals' }); + if (view === 'insights-jobs') setRoute({ name: 'insights-jobs' }); if (view === 'online-dashboard') setRoute({ name: 'online-evals' }); }} onExit={handleBack} @@ -336,11 +367,38 @@ function AppContent({ if (route.name === 'run-batch-eval') { const backRoute = route.from ?? 'run'; - return setRoute({ name: backRoute } as Route)} />; + return ( + setRoute({ name: backRoute } as Route)} + onViewJobs={() => setRoute({ name: 'batch-eval-history' })} + /> + ); + } + + if (route.name === 'run-ingest') { + const backRoute = route.from ?? 'run'; + return setRoute({ name: backRoute } as Route)} />; + } + + if (route.name === 'view') { + return ( + { + if (view === 'recommendation') setRoute({ name: 'recommendation-history' }); + if (view === 'batch-evaluation') setRoute({ name: 'batch-eval-history' }); + if (view === 'ab-test') setRoute({ name: 'ab-test-jobs' }); + }} + onExit={handleBack} + /> + ); } if (route.name === 'batch-eval-history') { - return setRoute({ name: 'evals' })} />; + return setRoute({ name: 'view' })} />; + } + + if (route.name === 'insights-jobs') { + return setRoute({ name: 'evals' })} />; } if (route.name === 'recommendations-hub') { @@ -356,12 +414,31 @@ function AppContent({ } if (route.name === 'recommend') { - const backRoute = route.from ?? 'recommendations-hub'; - return setRoute({ name: backRoute } as Route)} />; + const backRoute = route.from ?? 'run'; + return ( + setRoute({ name: backRoute } as Route)} + onViewJobs={() => setRoute({ name: 'recommendation-history' })} + /> + ); } if (route.name === 'recommendation-history') { - return setRoute({ name: 'recommendations-hub' })} />; + return setRoute({ name: 'view' })} />; + } + + if (route.name === 'run-ab-test') { + const backRoute = route.from ?? 'run'; + return ( + setRoute({ name: backRoute } as Route)} + onViewJobs={() => setRoute({ name: 'ab-test-jobs' })} + /> + ); + } + + if (route.name === 'ab-test-jobs') { + return setRoute({ name: 'view' })} />; } if (route.name === 'eval-runs') { @@ -405,8 +482,15 @@ function AppContent({ return setRoute({ name: 'help' })} />; } - if (route.name === 'ab-test') { - return ; + if (route.name === 'export-harness') { + return ( + setRoute({ name: 'deploy' })} + /> + ); } if (route.name === 'cli-only') { diff --git a/src/cli/tui/__tests__/app-command-coverage.test.ts b/src/cli/tui/__tests__/app-command-coverage.test.ts new file mode 100644 index 000000000..b19975d83 --- /dev/null +++ b/src/cli/tui/__tests__/app-command-coverage.test.ts @@ -0,0 +1,54 @@ +/** + * Regression test: every command shown on the TUI home screen must have a handler + * in onSelectCommand (either an explicit route, or an entry in CLI_ONLY_EXAMPLES). + * + * If this test fails, you added a command to the program but forgot to handle it + * in App.tsx's onSelectCommand function or add it to CLI_ONLY_EXAMPLES in copy.ts. + */ +import { createProgram } from '../../cli'; +import { CLI_ONLY_EXAMPLES } from '../copy'; +import { getCommandsForUI } from '../utils/commands'; +import { describe, expect, it } from 'vitest'; + +// These command IDs have explicit route handlers in App.tsx onSelectCommand +const ROUTED_COMMANDS = new Set([ + 'dev', + 'exec', + 'deploy', + 'invoke', + 'logs', + 'status', + 'create', + 'add', + 'remove', + 'run', + 'evals', + 'fetch', + 'view', + 'validate', + 'package', + 'import', + 'update', + 'config-bundle', + 'dataset', + 'batch-evaluations', +]); + +describe('TUI home screen command coverage', () => { + it('every visible command has either a route handler or CLI_ONLY_EXAMPLES entry', () => { + const program = createProgram(); + const commands = getCommandsForUI(program, { inProject: true }); + + const unhandled: string[] = []; + for (const cmd of commands) { + if (cmd.id === 'help') continue; // help is special-cased + const hasRoute = ROUTED_COMMANDS.has(cmd.id); + const hasCliOnly = cmd.id in CLI_ONLY_EXAMPLES; + if (!hasRoute && !hasCliOnly) { + unhandled.push(cmd.id); + } + } + + expect(unhandled).toEqual([]); + }); +}); diff --git a/src/cli/tui/components/DeployStatus.tsx b/src/cli/tui/components/DeployStatus.tsx index c6e78b3e4..837109a46 100644 --- a/src/cli/tui/components/DeployStatus.tsx +++ b/src/cli/tui/components/DeployStatus.tsx @@ -35,8 +35,12 @@ function extractProgress(messages: DeployMessage[]): { current: number; total: n * Progress bar component. */ function ProgressBar({ current, total }: { current: number; total: number }) { - const percent = total > 0 ? current / total : 0; - const filled = Math.round(percent * PROGRESS_BAR_WIDTH); + // CDK toolkit can briefly emit completed > total during graph expansion. + // Clamp here so the bar never asks String.repeat for a negative count. + const safeTotal = total > 0 ? total : 0; + const safeCurrent = Math.max(0, Math.min(current, safeTotal)); + const percent = safeTotal > 0 ? safeCurrent / safeTotal : 0; + const filled = Math.min(PROGRESS_BAR_WIDTH, Math.max(0, Math.round(percent * PROGRESS_BAR_WIDTH))); const empty = PROGRESS_BAR_WIDTH - filled; return ( @@ -47,7 +51,7 @@ function ProgressBar({ current, total }: { current: number; total: number }) { ] {' '} - {current}/{total} + {safeCurrent}/{safeTotal} ); diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index 6e24915b4..f7f748035 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -21,10 +21,10 @@ const ICONS = { 'policy-engine': '▣', policy: '▢', 'config-bundle': '⬡', - 'ab-test': '⚗', dataset: '▤', harness: '⬢', 'runtime-endpoint': '◉', + 'knowledge-base': '✚', payment: '₿', } as const; @@ -127,6 +127,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res const allAgents = project.runtimes ?? []; const agents = agentName ? allAgents.filter(a => a.name === agentName) : allAgents; const memories = project.memories ?? []; + const knowledgeBases = project.knowledgeBases ?? []; const credentials = project.credentials ?? []; const evaluators = project.evaluators ?? []; const onlineEvalConfigs = project.onlineEvalConfigs ?? []; @@ -136,8 +137,8 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res const policyEngines = project.policyEngines ?? []; const configBundles = project.configBundles ?? []; const datasets = project.datasets ?? []; - const abTests = project.abTests ?? []; const payments = project.payments ?? []; + const harnesses = project.harnesses ?? []; // Build lookup map and collect pending-removal resources in a single pass const { statusMap, pendingRemovals } = useMemo(() => { @@ -163,6 +164,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res const hasContent = agents.length > 0 || memories.length > 0 || + knowledgeBases.length > 0 || credentials.length > 0 || evaluators.length > 0 || onlineEvalConfigs.length > 0 || @@ -171,6 +173,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res mcpRuntimeTools.length > 0 || unassignedTargets.length > 0 || payments.length > 0 || + harnesses.length > 0 || pendingRemovals.length > 0; return ( @@ -246,6 +249,31 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res )} + {/* Knowledge Bases */} + {knowledgeBases.length > 0 && ( + + Knowledge Bases + {knowledgeBases.map(kb => { + const rsEntry = statusMap.get(`knowledge-base:${kb.name}`); + const dsCount = kb.dataSources.length; + const fallbackDetail = `${dsCount} data source${dsCount === 1 ? '' : 's'}`; + return ( + + ); + })} + + )} + {/* Credentials */} {credentials.length > 0 && ( @@ -298,7 +326,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res Online Eval Configs {onlineEvalConfigs.map(config => { const rsEntry = statusMap.get(`online-eval:${config.name}`); - const defaultDetail = `${config.evaluators.length} evaluator${config.evaluators.length !== 1 ? 's' : ''} — ${config.samplingRate}% sampling`; + const defaultDetail = `${(config.evaluators ?? []).length} evaluator${(config.evaluators ?? []).length !== 1 ? 's' : ''} — ${config.samplingRate}% sampling`; return ( )} - {/* AB Tests */} - {abTests.length > 0 && ( + {/* Harnesses */} + {harnesses.length > 0 && ( - AB Tests - {abTests.map(test => { - const rsEntry = statusMap.get(`ab-test:${test.name}`); + Harnesses + {harnesses.map(harness => { + const rsEntry = statusMap.get(`harness:${harness.name}`); return ( ); })} @@ -536,13 +563,14 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res {ICONS.agent} agent{' '} {ICONS.memory} memory{' '} + {ICONS['knowledge-base']} knowledge base{' '} {ICONS.credential} credential{' '} {ICONS.evaluator} evaluator{' '} {ICONS['online-eval']} online-eval{' '} {ICONS.gateway} gateway{' '} {ICONS['policy-engine']} policy engine{' '} {ICONS['config-bundle']} config bundle{' '} - {ICONS['ab-test']} ab test + {ICONS.harness} harness diff --git a/src/cli/tui/components/TextInput.tsx b/src/cli/tui/components/TextInput.tsx index 02ffe2bf0..abf353ded 100644 --- a/src/cli/tui/components/TextInput.tsx +++ b/src/cli/tui/components/TextInput.tsx @@ -108,9 +108,11 @@ export function TextInput({ // Get display value (masked or plain) const displayValue = mask ? mask.repeat(value.length) : value; - // Simple split for cursor positioning (used by both modes) + // Simple split for cursor positioning (used by both modes). + // When the input is empty, the cursor sits over the placeholder's first character so it + // stays visible (the dim placeholder remainder below renders placeholder.slice(1)). const beforeCursorFull = displayValue.slice(0, cursor); - const charAtCursorFull = displayValue[cursor] ?? ' '; + const charAtCursorFull = displayValue[cursor] ?? (!value && placeholder ? (placeholder[0] ?? ' ') : ' '); const afterCursorFull = displayValue.slice(cursor + 1); if (expandable) { diff --git a/src/cli/tui/components/__tests__/DeployStatus.test.tsx b/src/cli/tui/components/__tests__/DeployStatus.test.tsx index b661ec13c..63aaa26e2 100644 --- a/src/cli/tui/components/__tests__/DeployStatus.test.tsx +++ b/src/cli/tui/components/__tests__/DeployStatus.test.tsx @@ -154,6 +154,21 @@ describe('DeployStatus', () => { // Should show the latest progress expect(lastFrame()).toContain('7/10'); }); + + it('clamps when CDK reports completed greater than total without throwing', () => { + // CDK toolkit can briefly report completed > total during graph expansion. + // Before the clamp, this asked String.repeat for a negative count and crashed + // the deploy TUI with "Invalid count value: -10". + const messages = [makeMsg('overflow', 'CDK_TOOLKIT_I5502', { completed: 50, total: 30 })]; + + expect(() => render()).not.toThrow(); + }); + + it('clamps when CDK reports a negative completed count', () => { + const messages = [makeMsg('underflow', 'CDK_TOOLKIT_I5502', { completed: -5, total: 10 })]; + + expect(() => render()).not.toThrow(); + }); }); describe('warning state (post-deploy errors)', () => { diff --git a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx index 875cb42f0..04efa7b22 100644 --- a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx +++ b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx @@ -9,6 +9,7 @@ const baseProject: AgentCoreProjectSpec = { name: 'test-project', runtimes: [], memories: [], + knowledgeBases: [], credentials: [], } as unknown as AgentCoreProjectSpec; diff --git a/src/cli/tui/components/__tests__/TextInput.test.tsx b/src/cli/tui/components/__tests__/TextInput.test.tsx index 79865a607..746f6ba47 100644 --- a/src/cli/tui/components/__tests__/TextInput.test.tsx +++ b/src/cli/tui/components/__tests__/TextInput.test.tsx @@ -20,13 +20,15 @@ describe('TextInput', () => { expect(lastFrame()).toContain('Enter name:'); }); - it('renders placeholder when value is empty', () => { + it('renders the full placeholder (including its first char) when value is empty', () => { const { lastFrame } = render( ); - // Placeholder shows all chars after cursor position (slice(1)) - expect(lastFrame()).toContain('y-agent'); + // The first placeholder char sits under the cursor, the rest renders dim — together + // the full placeholder must be visible (regression guard for the slice(1) truncation bug + // that rendered "my-agent" as "y-agent"). + expect(lastFrame()).toContain('my-agent'); }); it('renders initial value', () => { diff --git a/src/cli/tui/components/jwt-config/DomainOverridesManager.tsx b/src/cli/tui/components/jwt-config/DomainOverridesManager.tsx new file mode 100644 index 000000000..0326a6673 --- /dev/null +++ b/src/cli/tui/components/jwt-config/DomainOverridesManager.tsx @@ -0,0 +1,219 @@ +import { useListNavigation } from '../../hooks'; +import type { SelectableItem } from '../index'; +import { TextInput } from '../index'; +import type { DomainOverrideEntry, DomainOverridesManagerMode } from './types'; +import { formatOverrideSummary } from './types'; +import { Box, Text } from 'ink'; +import React, { useCallback, useMemo, useState } from 'react'; + +const MAX_OVERRIDES = 5; +const RCFG_PATTERN = + /^((rcfg-[0-9a-z]{17})|(arn:[a-z0-9-]+:vpc-lattice:[a-zA-Z0-9-]+:\d{12}:resourceconfiguration\/rcfg-[0-9a-z]{17}))$/; + +export interface DomainOverridesManagerProps { + initialOverrides: DomainOverrideEntry[]; + onDone: (overrides: DomainOverrideEntry[]) => void; + onCancel: () => void; + onModeChange?: (mode: DomainOverridesManagerMode) => void; +} + +/** + * Repeatable list of per-domain private-endpoint overrides (Lattice-only). Mirrors + * CustomClaimsManager's list/add/edit/delete mode machine; each entry is a {domain, rcfg-id} pair. + * Only offered under the self-managed (VPC Lattice) arm, so every override is a lattice resource — + * which is exactly what the service and the AWS Console expose. + */ +export function DomainOverridesManager({ + initialOverrides, + onDone, + onCancel, + onModeChange, +}: DomainOverridesManagerProps) { + const [overrides, setOverrides] = useState(initialOverrides); + const [mode, setMode] = useState(initialOverrides.length > 0 ? 'list' : 'add'); + const [editIndex, setEditIndex] = useState(-1); + // Two-field form: capture the domain, then the rcfg id. + const [formField, setFormField] = useState<'domain' | 'rcfg'>('domain'); + const [draftDomain, setDraftDomain] = useState(''); + + React.useEffect(() => { + onModeChange?.(mode); + }, [mode, onModeChange]); + + const atLimit = overrides.length >= MAX_OVERRIDES; + + const actionItems = useMemo(() => { + const items: SelectableItem[] = []; + if (!atLimit) items.push({ id: 'add', title: 'Add domain override' }); + if (overrides.length > 0) { + items.push({ id: 'edit', title: 'Edit existing override' }); + items.push({ id: 'delete', title: 'Delete override' }); + } + items.push({ id: 'done', title: 'Done' }); + return items; + }, [overrides.length, atLimit]); + + const startAdd = useCallback(() => { + setEditIndex(-1); + setDraftDomain(''); + setFormField('domain'); + setMode('add'); + }, []); + + const actionNav = useListNavigation({ + items: actionItems, + onSelect: item => { + if (item.id === 'add') startAdd(); + else if (item.id === 'edit') setMode('edit-pick'); + else if (item.id === 'delete') setMode('delete-pick'); + else if (item.id === 'done') onDone(overrides); + }, + onExit: onCancel, + isActive: mode === 'list', + }); + + const pickerItems = useMemo( + () => overrides.map((o, i) => ({ id: String(i), title: formatOverrideSummary(o) })), + [overrides] + ); + + const editPickerNav = useListNavigation({ + items: pickerItems, + onSelect: (_, index) => { + setEditIndex(index); + setDraftDomain(overrides[index]?.domain ?? ''); + setFormField('domain'); + setMode('edit'); + }, + onExit: () => setMode('list'), + isActive: mode === 'edit-pick', + }); + + const deletePickerNav = useListNavigation({ + items: pickerItems, + onSelect: (_, index) => { + setOverrides(prev => { + const next = prev.filter((_, i) => i !== index); + setMode(next.length === 0 ? 'add' : 'list'); + return next; + }); + }, + onExit: () => setMode('list'), + isActive: mode === 'delete-pick', + }); + + const isFormMode = mode === 'add' || mode === 'edit'; + + const cancelForm = useCallback(() => { + if (overrides.length > 0) setMode('list'); + else onCancel(); + }, [overrides.length, onCancel]); + + const submitOverride = useCallback( + (rcfg: string) => { + const entry: DomainOverrideEntry = { domain: draftDomain.trim(), resourceConfigurationId: rcfg.trim() }; + if (mode === 'edit' && editIndex >= 0) { + setOverrides(prev => prev.map((o, i) => (i === editIndex ? entry : o))); + } else { + setOverrides(prev => [...prev, entry]); + } + setEditIndex(-1); + setMode('list'); + }, + [draftDomain, mode, editIndex] + ); + + return ( + + Per-domain private-endpoint overrides (optional) + + Map an additional IdP domain (e.g. a private jwks_uri) to its own VPC Lattice resource — up to {MAX_OVERRIDES}. + + + {mode === 'list' && ( + + {overrides.length > 0 && ( + + {overrides.map((o, i) => ( + + {i + 1}. {formatOverrideSummary(o)} + + ))} + + )} + + {actionItems.map((item, idx) => { + const isCursor = idx === actionNav.selectedIndex; + return ( + + {isCursor ? '❯' : ' '} {item.title} + + ); + })} + + + )} + + {mode === 'edit-pick' && ( + + Select an override to edit: + {pickerItems.map((item, idx) => { + const isCursor = idx === editPickerNav.selectedIndex; + return ( + + {isCursor ? '❯' : ' '} {item.title} + + ); + })} + + )} + + {mode === 'delete-pick' && ( + + Select an override to delete: + {pickerItems.map((item, idx) => { + const isCursor = idx === deletePickerNav.selectedIndex; + return ( + + {isCursor ? '❯' : ' '} {item.title} + + ); + })} + + )} + + {isFormMode && formField === 'domain' && ( + + { + setDraftDomain(value.trim()); + setFormField('rcfg'); + }} + onCancel={cancelForm} + customValidation={value => + (value.trim().length >= 1 && value.trim().length <= 253) || 'Domain must be 1-253 characters' + } + /> + + )} + + {isFormMode && formField === 'rcfg' && ( + + = 0 ? overrides[editIndex]?.resourceConfigurationId : ''} + onSubmit={submitOverride} + onCancel={() => setFormField('domain')} + customValidation={value => + RCFG_PATTERN.test(value.trim()) || 'Must be a VPC Lattice resource-config id (rcfg-...) or its ARN' + } + /> + + )} + + ); +} diff --git a/src/cli/tui/components/jwt-config/JwtConfigInput.tsx b/src/cli/tui/components/jwt-config/JwtConfigInput.tsx index 871af8951..193df6dd7 100644 --- a/src/cli/tui/components/jwt-config/JwtConfigInput.tsx +++ b/src/cli/tui/components/jwt-config/JwtConfigInput.tsx @@ -1,11 +1,42 @@ -import { useMultiSelectNavigation } from '../../hooks'; -import { SecretInput, TextInput, WizardMultiSelect } from '../index'; +import { + LATTICE_RESOURCE_CONFIG_PATTERN, + SECURITY_GROUP_ID_PATTERN, + SUBNET_ID_PATTERN, +} from '../../../../schema/schemas/auth'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; +import { SecretInput, TextInput, WizardMultiSelect, WizardSelect } from '../index'; import { CustomClaimsManager } from './CustomClaimsManager'; -import type { ClaimsManagerMode, ConstraintType, CustomClaimEntry, JwtSubStep } from './types'; -import { CONSTRAINT_ITEMS, OIDC_WELL_KNOWN_SUFFIX, validateCommaSeparated } from './types'; +import { DomainOverridesManager } from './DomainOverridesManager'; +import type { + ClaimsManagerMode, + ConstraintType, + CustomClaimEntry, + DomainOverrideEntry, + DomainOverridesManagerMode, + JwtSubStep, +} from './types'; +import { + CONSTRAINT_ITEMS, + ENDPOINT_IP_TYPE_ITEMS, + OIDC_WELL_KNOWN_SUFFIX, + PRIVATE_ENDPOINT_TYPE_ITEMS, + validateCommaSeparated, +} from './types'; import { Box, Text } from 'ink'; import React from 'react'; +/** Validate a comma-separated list of ids against a schema regex (matches the CLI flag path strictness). */ +function validateIdList(value: string, pattern: RegExp, label: string, max?: number): true | string { + const ids = value + .split(',') + .map(s => s.trim()) + .filter(Boolean); + if (ids.length === 0) return 'At least one value is required'; + if (max && ids.length > max) return `At most ${max} allowed`; + const bad = ids.find(id => !pattern.test(id)); + return bad ? `Invalid ${label}: "${bad}"` : true; +} + export interface JwtConfigInputProps { subStep: JwtSubStep; steps: JwtSubStep[]; @@ -15,12 +46,28 @@ export interface JwtConfigInputProps { audience: string; clients: string; scopes: string; + // PrivateLink inbound (harness-only; optional so Gateway/Agent/Generate callers are unaffected). + latticeResourceId?: string; + vpcId?: string; + vpcSubnets?: string; + vpcSecurityGroups?: string; + vpcRoutingDomain?: string; + domainOverrides?: DomainOverrideEntry[]; onDiscoveryUrl: (url: string) => void; onConstraintsPicked: (selectedIds: string[]) => void; onAudience: (audience: string) => void; onClients: (clients: string) => void; onScopes: (scopes: string) => void; onCustomClaimsDone: (claims: CustomClaimEntry[]) => void; + onPrivateEndpointType?: (type: string) => void; + onLatticeResourceId?: (value: string) => void; + onVpcId?: (value: string) => void; + onVpcSubnets?: (value: string) => void; + onVpcIpType?: (value: string) => void; + onVpcSecurityGroups?: (value: string) => void; + onVpcRoutingDomain?: (value: string) => void; + onDomainOverridesDone?: (overrides: DomainOverrideEntry[]) => void; + onOverridesManagerModeChange?: (mode: DomainOverridesManagerMode) => void; onClientId: (clientId: string) => void; onClientIdSkip: () => void; onClientSecret: (clientSecret: string) => void; @@ -37,12 +84,27 @@ export function JwtConfigInput({ audience, clients, scopes, + latticeResourceId = '', + vpcId = '', + vpcSubnets = '', + vpcSecurityGroups = '', + vpcRoutingDomain = '', + domainOverrides = [], onDiscoveryUrl, onConstraintsPicked, onAudience, onClients, onScopes, onCustomClaimsDone, + onPrivateEndpointType, + onLatticeResourceId, + onVpcId, + onVpcSubnets, + onVpcIpType, + onVpcSecurityGroups, + onVpcRoutingDomain, + onDomainOverridesDone, + onOverridesManagerModeChange, onClientId, onClientIdSkip, onClientSecret, @@ -65,6 +127,20 @@ export function JwtConfigInput({ requireSelection: true, }); + const privateEndpointTypeNav = useListNavigation({ + items: PRIVATE_ENDPOINT_TYPE_ITEMS, + onSelect: item => onPrivateEndpointType?.(item.id), + onExit: () => onBack(), + isActive: subStep === 'privateEndpointType', + }); + + const ipTypeNav = useListNavigation({ + items: ENDPOINT_IP_TYPE_ITEMS, + onSelect: item => onVpcIpType?.(item.id), + onExit: () => onBack(), + isActive: subStep === 'vpcIpType', + }); + return ( Configure Custom JWT Authorizer @@ -146,6 +222,89 @@ export function JwtConfigInput({ onModeChange={onClaimsManagerModeChange} /> )} + {subStep === 'privateEndpointType' && ( + + )} + {subStep === 'latticeResourceId' && ( + onLatticeResourceId?.(v)} + onCancel={onBack} + customValidation={value => + LATTICE_RESOURCE_CONFIG_PATTERN.test(value.trim()) || + 'Must be a VPC Lattice resource-config id (rcfg-...) or ARN' + } + /> + )} + {subStep === 'domainOverrides' && ( + onDomainOverridesDone?.(overrides)} + onCancel={onBack} + onModeChange={onOverridesManagerModeChange} + /> + )} + {subStep === 'vpcId' && ( + onVpcId?.(v)} + onCancel={onBack} + customValidation={value => + /^vpc-(([0-9a-z]{8})|([0-9a-z]{17}))$/.test(value.trim()) || 'Must be a VPC id (vpc-...)' + } + /> + )} + {subStep === 'vpcSubnets' && ( + onVpcSubnets?.(v)} + onCancel={onBack} + customValidation={value => validateIdList(value, SUBNET_ID_PATTERN, 'subnet id (subnet-...)')} + /> + )} + {subStep === 'vpcIpType' && ( + + )} + {subStep === 'vpcSecurityGroups' && ( + onVpcSecurityGroups?.(v)} + onCancel={onBack} + allowEmpty + customValidation={value => + value.trim() === '' + ? true + : validateIdList(value, SECURITY_GROUP_ID_PATTERN, 'security group id (sg-...)', 5) + } + /> + )} + {subStep === 'vpcRoutingDomain' && ( + onVpcRoutingDomain?.(v)} + onCancel={onBack} + allowEmpty + /> + )} {subStep === 'clientId' && ( Optional: Provide OAuth credentials for bearer token fetching diff --git a/src/cli/tui/components/jwt-config/index.ts b/src/cli/tui/components/jwt-config/index.ts index 4b15714ce..c2e90c9c7 100644 --- a/src/cli/tui/components/jwt-config/index.ts +++ b/src/cli/tui/components/jwt-config/index.ts @@ -4,4 +4,5 @@ export { useJwtConfigFlow } from './useJwtConfigFlow'; export type { JwtConfig } from './useJwtConfigFlow'; export { CustomClaimsManager } from './CustomClaimsManager'; export { CustomClaimForm } from './CustomClaimForm'; +export { DomainOverridesManager } from './DomainOverridesManager'; export * from './types'; diff --git a/src/cli/tui/components/jwt-config/types.ts b/src/cli/tui/components/jwt-config/types.ts index fdb477772..4814aa974 100644 --- a/src/cli/tui/components/jwt-config/types.ts +++ b/src/cli/tui/components/jwt-config/types.ts @@ -9,9 +9,39 @@ export type JwtSubStep = | 'clients' | 'scopes' | 'customClaims' + | 'privateEndpointType' + | 'latticeResourceId' + | 'domainOverrides' + | 'vpcId' + | 'vpcSubnets' + | 'vpcIpType' + | 'vpcSecurityGroups' + | 'vpcRoutingDomain' | 'clientId' | 'clientSecret'; +/** Which PrivateLink endpoint arm the user is configuring (or none). */ +export type PrivateEndpointType = 'none' | 'lattice' | 'vpc'; + +export const PRIVATE_ENDPOINT_TYPE_ITEMS: SelectableItem[] = [ + { id: 'none', title: 'None', description: 'The IdP discovery endpoint is publicly reachable' }, + { + id: 'lattice', + title: 'VPC Lattice resource', + description: 'Reach the discovery endpoint via a self-managed VPC Lattice resource configuration', + }, + { + id: 'vpc', + title: 'Managed VPC endpoint', + description: 'Reach the discovery endpoint via a service-managed VPC interface endpoint', + }, +]; + +export const ENDPOINT_IP_TYPE_ITEMS: SelectableItem[] = [ + { id: 'IPV4', title: 'IPV4', description: 'IPv4 addressing' }, + { id: 'IPV6', title: 'IPV6', description: 'IPv6 addressing' }, +]; + export type ClaimValueType = 'STRING' | 'STRING_ARRAY'; export type ClaimOperator = 'EQUALS' | 'CONTAINS' | 'CONTAINS_ANY'; @@ -24,6 +54,22 @@ export interface CustomClaimEntry { export type ClaimsManagerMode = 'list' | 'add' | 'edit-pick' | 'edit' | 'delete-pick'; +/** + * A per-domain private-endpoint override. Lattice-only: a domain mapped to its own VPC Lattice + * resource-config (mirrors the AWS Console, which surfaces overrides only under the self-managed arm + * and only as a {domain, resourceConfigurationId} pair). + */ +export interface DomainOverrideEntry { + domain: string; + resourceConfigurationId: string; +} + +export type DomainOverridesManagerMode = 'list' | 'add' | 'edit-pick' | 'edit' | 'delete-pick'; + +export function formatOverrideSummary(o: DomainOverrideEntry): string { + return `${o.domain} → ${o.resourceConfigurationId}`; +} + export const CONSTRAINT_ITEMS: SelectableItem[] = [ { id: 'audience', title: 'Allowed Audiences', description: 'Validate token audience claims' }, { id: 'clients', title: 'Allowed Clients', description: 'Validate client identifiers in the token' }, diff --git a/src/cli/tui/components/jwt-config/useJwtConfigFlow.ts b/src/cli/tui/components/jwt-config/useJwtConfigFlow.ts index 73fcff14d..8547232ac 100644 --- a/src/cli/tui/components/jwt-config/useJwtConfigFlow.ts +++ b/src/cli/tui/components/jwt-config/useJwtConfigFlow.ts @@ -1,5 +1,18 @@ -import type { CustomClaimValidation } from '../../../../schema'; -import type { ClaimsManagerMode, ConstraintType, CustomClaimEntry, JwtSubStep } from './types'; +import type { + CustomClaimValidation, + EndpointIpAddressType, + PrivateEndpoint, + PrivateEndpointOverride, +} from '../../../../schema'; +import type { + ClaimsManagerMode, + ConstraintType, + CustomClaimEntry, + DomainOverrideEntry, + DomainOverridesManagerMode, + JwtSubStep, + PrivateEndpointType, +} from './types'; import { useCallback, useMemo, useState } from 'react'; export interface JwtConfig { @@ -10,14 +23,20 @@ export interface JwtConfig { customClaims?: CustomClaimValidation[]; clientId?: string; clientSecret?: string; + /** PrivateLink inbound endpoint for reaching the OIDC discovery URL (singular arm). */ + privateEndpoint?: PrivateEndpoint; + /** Per-domain private-endpoint overrides (Lattice-only; ≤5). */ + privateEndpointOverrides?: PrivateEndpointOverride[]; } interface UseJwtConfigFlowOptions { onComplete: (jwtConfig: JwtConfig) => void; onBack: () => void; + /** Enable the PrivateLink-inbound sub-steps (harness only). Defaults to false. */ + enablePrivateEndpoint?: boolean; } -export function useJwtConfigFlow({ onComplete, onBack }: UseJwtConfigFlowOptions) { +export function useJwtConfigFlow({ onComplete, onBack, enablePrivateEndpoint = false }: UseJwtConfigFlowOptions) { const [subStep, setSubStep] = useState('discoveryUrl'); const [discoveryUrl, setDiscoveryUrl] = useState(''); const [selectedConstraints, setSelectedConstraints] = useState>(new Set()); @@ -27,17 +46,36 @@ export function useJwtConfigFlow({ onComplete, onBack }: UseJwtConfigFlowOptions const [customClaims, setCustomClaims] = useState([]); const [clientId, setClientId] = useState(''); const [claimsManagerMode, setClaimsManagerMode] = useState('add'); + // PrivateLink inbound state + const [privateEndpointType, setPrivateEndpointType] = useState('none'); + const [latticeResourceId, setLatticeResourceId] = useState(''); + const [vpcId, setVpcId] = useState(''); + const [vpcSubnets, setVpcSubnets] = useState(''); + const [vpcIpType, setVpcIpType] = useState('IPV4'); + const [vpcSecurityGroups, setVpcSecurityGroups] = useState(''); + const [vpcRoutingDomain, setVpcRoutingDomain] = useState(''); + const [domainOverrides, setDomainOverrides] = useState([]); + const [overridesManagerMode, setOverridesManagerMode] = useState('list'); - // Compute the ordered list of JWT sub-steps based on selected constraints + // Compute the ordered list of JWT sub-steps based on selected constraints + private-endpoint arm const steps = useMemo(() => { const result: JwtSubStep[] = ['discoveryUrl', 'constraintPicker']; if (selectedConstraints.has('audience')) result.push('audience'); if (selectedConstraints.has('clients')) result.push('clients'); if (selectedConstraints.has('scopes')) result.push('scopes'); if (selectedConstraints.has('customClaims')) result.push('customClaims'); + if (enablePrivateEndpoint) { + result.push('privateEndpointType'); + if (privateEndpointType === 'lattice') { + // Per-domain overrides are Lattice-only (matches the service + AWS Console). + result.push('latticeResourceId', 'domainOverrides'); + } else if (privateEndpointType === 'vpc') { + result.push('vpcId', 'vpcSubnets', 'vpcIpType', 'vpcSecurityGroups', 'vpcRoutingDomain'); + } + } result.push('clientId', 'clientSecret'); return result; - }, [selectedConstraints]); + }, [selectedConstraints, privateEndpointType, enablePrivateEndpoint]); const stepIndex = steps.indexOf(subStep); @@ -61,14 +99,47 @@ export function useJwtConfigFlow({ onComplete, onBack }: UseJwtConfigFlowOptions .map(v => v.trim()) .filter(Boolean); + const buildPrivateEndpoint = useCallback((): PrivateEndpoint | undefined => { + if (privateEndpointType === 'lattice' && latticeResourceId.trim()) { + return { selfManagedLatticeResource: { resourceConfigurationIdentifier: latticeResourceId.trim() } }; + } + if (privateEndpointType === 'vpc' && vpcId.trim()) { + const sgs = parseList(vpcSecurityGroups); + return { + managedVpcResource: { + vpcIdentifier: vpcId.trim(), + subnetIds: parseList(vpcSubnets), + endpointIpAddressType: vpcIpType, + ...(sgs.length > 0 ? { securityGroupIds: sgs } : {}), + ...(vpcRoutingDomain.trim() ? { routingDomain: vpcRoutingDomain.trim() } : {}), + }, + }; + } + return undefined; + }, [privateEndpointType, latticeResourceId, vpcId, vpcSubnets, vpcIpType, vpcSecurityGroups, vpcRoutingDomain]); + const finishConfig = useCallback( (clientSecret: string) => { const audienceList = selectedConstraints.has('audience') ? parseList(audience) : undefined; const clientsList = selectedConstraints.has('clients') ? parseList(clients) : undefined; const scopesList = selectedConstraints.has('scopes') ? parseList(scopes) : undefined; + const privateEndpoint = buildPrivateEndpoint(); + // Overrides are Lattice-only and only collected under the lattice arm, so each maps to a + // selfManagedLatticeResource — keeping every endpoint the same arm (the service's rule). + const overrides: PrivateEndpointOverride[] | undefined = + privateEndpointType === 'lattice' && domainOverrides.length > 0 + ? domainOverrides.map(o => ({ + domain: o.domain, + privateEndpoint: { + selfManagedLatticeResource: { resourceConfigurationIdentifier: o.resourceConfigurationId }, + }, + })) + : undefined; const config: JwtConfig = { discoveryUrl, + ...(privateEndpoint ? { privateEndpoint } : {}), + ...(overrides ? { privateEndpointOverrides: overrides } : {}), ...(audienceList && audienceList.length > 0 ? { allowedAudience: audienceList } : {}), ...(clientsList && clientsList.length > 0 ? { allowedClients: clientsList } : {}), ...(scopesList && scopesList.length > 0 ? { allowedScopes: scopesList } : {}), @@ -98,7 +169,19 @@ export function useJwtConfigFlow({ onComplete, onBack }: UseJwtConfigFlowOptions onComplete(config); setSubStep('discoveryUrl'); }, - [selectedConstraints, audience, clients, scopes, discoveryUrl, customClaims, clientId, onComplete] + [ + selectedConstraints, + audience, + clients, + scopes, + discoveryUrl, + customClaims, + clientId, + buildPrivateEndpoint, + privateEndpointType, + domainOverrides, + onComplete, + ] ); const handlers = { @@ -106,17 +189,53 @@ export function useJwtConfigFlow({ onComplete, onBack }: UseJwtConfigFlowOptions setDiscoveryUrl(url); setSubStep('constraintPicker'); }, - handleConstraintsPicked: useCallback((selectedIds: string[]) => { - const constraints = new Set(selectedIds as ConstraintType[]); - setSelectedConstraints(constraints); - const order: ConstraintType[] = ['audience', 'clients', 'scopes', 'customClaims']; - const first = order.find(c => constraints.has(c)); - if (first) { - setSubStep(first); - } else { - setSubStep('clientId'); - } + handleConstraintsPicked: useCallback( + (selectedIds: string[]) => { + const constraints = new Set(selectedIds as ConstraintType[]); + setSelectedConstraints(constraints); + const order: ConstraintType[] = ['audience', 'clients', 'scopes', 'customClaims']; + const first = order.find(c => constraints.has(c)); + // Private-endpoint type follows the constraints block when enabled; else jump to clientId. + setSubStep(first ?? (enablePrivateEndpoint ? 'privateEndpointType' : 'clientId')); + }, + [enablePrivateEndpoint] + ), + handlePrivateEndpointType: (type: string) => { + setPrivateEndpointType(type as PrivateEndpointType); + // Step list recomputes from privateEndpointType; advance to the first step after it. + if (type === 'lattice') setSubStep('latticeResourceId'); + else if (type === 'vpc') setSubStep('vpcId'); + else setSubStep('clientId'); + }, + handleLatticeResourceId: (value: string) => { + setLatticeResourceId(value); + setSubStep('domainOverrides'); + }, + handleDomainOverridesDone: useCallback((entries: DomainOverrideEntry[]) => { + setDomainOverrides(entries); + setSubStep('clientId'); }, []), + handleOverridesManagerModeChange: setOverridesManagerMode, + handleVpcId: (value: string) => { + setVpcId(value); + setSubStep('vpcSubnets'); + }, + handleVpcSubnets: (value: string) => { + setVpcSubnets(value); + setSubStep('vpcIpType'); + }, + handleVpcIpType: (value: string) => { + setVpcIpType(value as EndpointIpAddressType); + setSubStep('vpcSecurityGroups'); + }, + handleVpcSecurityGroups: (value: string) => { + setVpcSecurityGroups(value); + setSubStep('vpcRoutingDomain'); + }, + handleVpcRoutingDomain: (value: string) => { + setVpcRoutingDomain(value); + setSubStep('clientId'); + }, handleAudience: (value: string) => { setAudience(value); goNext(); @@ -160,6 +279,15 @@ export function useJwtConfigFlow({ onComplete, onBack }: UseJwtConfigFlowOptions clients, scopes, claimsManagerMode, + privateEndpointType, + latticeResourceId, + vpcId, + vpcSubnets, + vpcIpType, + vpcSecurityGroups, + vpcRoutingDomain, + domainOverrides, + overridesManagerMode, goBack, handlers, }; diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index bf1509f4a..794dc000b 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -24,6 +24,43 @@ export const QUICK_START = { tip: 'Coding agents can implement project and config changes', } as const; +/** + * Command descriptions used in CLI help and TUI. + */ +export const COMMAND_DESCRIPTIONS = { + /** Main program description */ + program: 'Build and deploy Agentic AI applications on AgentCore', + /** Command descriptions */ + add: 'Add resources to project config.', + create: 'Create a new AgentCore project', + deploy: 'Deploy project infrastructure to AWS via CDK.', + dev: 'Launch local dev server, or invoke an agent locally.', + invoke: 'Invoke a deployed agent endpoint.', + logs: 'Stream or search agent runtime logs.', + package: 'Package agent artifacts without deploying.', + remove: 'Remove resources from project config.', + status: 'Show deployed resource details and status.', + traces: 'View and download agent traces.', + evals: 'View saved eval and batch eval results from past runs.', + feedback: 'Send feedback about the AgentCore CLI to the team.', + fetch: 'Fetch access info for deployed resources.', + pause: 'Pause a deployed resource (online eval config, A/B test).', + resume: 'Resume a paused resource (online eval config, A/B test).', + recommend: 'Run optimization recommendations for system prompts and tool descriptions.', + recommendations: 'View recommendation jobs and their results.', + batchEvaluations: 'View batch evaluation jobs and their results.', + abTests: 'View A/B test jobs and their results.', + insights: '[preview] Manage insights analysis jobs.', + run: 'Run evaluations, batch evaluations, insights [preview], or optimization recommendations.', + stop: 'Stop a running batch evaluation or A/B test.', + import: 'Import a runtime, memory, or starter toolkit into this project. [experimental]', + telemetry: 'Manage anonymous usage analytics preferences.', + update: 'Check for and install CLI updates', + validate: 'Validate agentcore/ config files.', + 'config-bundle': 'Manage configuration bundle versions and diffs.', + archive: 'Archive (delete) a batch evaluation or recommendation on the service and clear local history.', +} as const; + /** * CLI-only command examples and usage information. * These commands must run in the terminal, not in the TUI. @@ -59,7 +96,7 @@ export const CLI_ONLY_EXAMPLES: Record --json', ], }, + 'run-insights': { + description: '[preview] Run failure analysis on agent sessions. This command runs in the terminal.', + examples: [ + 'agentcore run insights -r MyAgent -i FailureAnalysis', + 'agentcore run insights -r MyAgent -i FailureAnalysis --lookback 7', + 'agentcore run insights -r MyAgent -i FailureAnalysis --wait', + ], + }, + feedback: { + description: 'Send feedback about the AgentCore CLI to the team.', + examples: ['agentcore feedback', 'agentcore feedback --screenshot'], + }, + config: { + description: 'Adjust global configuration settings such as telemetry opt-out status.', + examples: ['agentcore config'], + }, + insights: { + description: '[preview] View insights analysis jobs and results.', + examples: ['agentcore insights history', 'agentcore insights results --id '], + }, }; diff --git a/src/cli/tui/hooks/__tests__/useDevDeploy.test.tsx b/src/cli/tui/hooks/__tests__/useDevDeploy.test.tsx index de56d0e29..690055c55 100644 --- a/src/cli/tui/hooks/__tests__/useDevDeploy.test.tsx +++ b/src/cli/tui/hooks/__tests__/useDevDeploy.test.tsx @@ -1,14 +1,40 @@ import { useDevDeploy } from '../useDevDeploy.js'; import { Text } from 'ink'; import { render } from 'ink-testing-library'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const mockHandleDeploy = vi.fn(); +const { mockHandleDeploy, mockReadProjectSpec, mockEnsureDefaultDeploymentTarget, mockCanSkipDeploy } = vi.hoisted( + () => ({ + mockHandleDeploy: vi.fn(), + mockReadProjectSpec: vi.fn(), + mockEnsureDefaultDeploymentTarget: vi.fn(), + mockCanSkipDeploy: vi.fn(), + }) +); vi.mock('../../../commands/deploy/actions.js', () => ({ handleDeploy: (...args: unknown[]) => mockHandleDeploy(...args), })); +// The mount effect now reads the project spec, ensures a deploy target, and checks +// for changes before deploying. Mock those so the effect reaches handleDeploy instead +// of hanging/erroring on the real ConfigIO (no project on disk in tests). Keep the rest +// of `lib` intact (getErrorMessage et al. are resolved through it) and override only ConfigIO. +vi.mock('../../../../lib', async importActual => ({ + ...(await importActual()), + ConfigIO: vi.fn(function (this: Record) { + this.readProjectSpec = mockReadProjectSpec; + }), +})); + +vi.mock('../../../operations/deploy', () => ({ + ensureDefaultDeploymentTarget: (...args: unknown[]) => mockEnsureDefaultDeploymentTarget(...args), +})); + +vi.mock('../../../operations/deploy/change-detection', () => ({ + canSkipDeploy: (...args: unknown[]) => mockCanSkipDeploy(...args), +})); + function Harness({ skip }: { skip?: boolean }) { const { steps, isComplete, error } = useDevDeploy({ skip }); return ( @@ -19,6 +45,14 @@ function Harness({ skip }: { skip?: boolean }) { } describe('useDevDeploy', () => { + beforeEach(() => { + // Default: a deployable project (has a harness) with changes to deploy, so the + // effect proceeds to handleDeploy. Individual tests override handleDeploy's result. + mockReadProjectSpec.mockResolvedValue({ harnesses: [{ name: 'test-harness' }] }); + mockEnsureDefaultDeploymentTarget.mockResolvedValue(undefined); + mockCanSkipDeploy.mockResolvedValue(false); + }); + afterEach(() => { vi.clearAllMocks(); }); diff --git a/src/cli/tui/hooks/__tests__/usePanelNavigation.test.tsx b/src/cli/tui/hooks/__tests__/usePanelNavigation.test.tsx index 89182b2e5..5df2685fa 100644 --- a/src/cli/tui/hooks/__tests__/usePanelNavigation.test.tsx +++ b/src/cli/tui/hooks/__tests__/usePanelNavigation.test.tsx @@ -212,7 +212,7 @@ describe('usePanelNavigation', () => { if (nav.position.layer === 'active') { nav.deactivate(); } - }, [nav.position.layer, nav.position.column, nav.position.field, nav.deactivate]); + }, [nav]); return ( diff --git a/src/cli/tui/hooks/__tests__/useRemove.test.tsx b/src/cli/tui/hooks/__tests__/useRemove.test.tsx index e0ed7f9ac..aefe2cd2c 100644 --- a/src/cli/tui/hooks/__tests__/useRemove.test.tsx +++ b/src/cli/tui/hooks/__tests__/useRemove.test.tsx @@ -81,6 +81,15 @@ vi.mock('../../../logging', () => ({ })), })); +// Mock the telemetry wrapper so it just runs the inner fn. Without this, the real +// withCommandRunTelemetry awaits getTelemetryClient()/flush(), which never settles +// in tests — leaving useRemoveResource stuck at isLoading:true with a null result. +vi.mock('../../../telemetry/cli-command-run.js', () => ({ + withCommandRunTelemetry: vi.fn((_command: string, _attrs: unknown, fn: (recorder: unknown) => unknown) => + fn({ set: vi.fn(), get: vi.fn(() => ({})) }) + ), +})); + function delay(ms = 100) { return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/src/cli/tui/hooks/index.ts b/src/cli/tui/hooks/index.ts index ef420ccc6..8223f9823 100644 --- a/src/cli/tui/hooks/index.ts +++ b/src/cli/tui/hooks/index.ts @@ -6,7 +6,13 @@ export { useExitHandler } from './useExitHandler'; export { useListNavigation } from './useListNavigation'; export { useMultiSelectNavigation } from './useMultiSelectNavigation'; export { useResponsive } from './useResponsive'; -export { useAvailableAgents, useCreateGateway, useExistingGateways } from './useCreateMcp'; +export { + useAvailableAgents, + useCreateGateway, + useExistingGateways, + useExistingKnowledgeBases, + useMcpGatewayNames, +} from './useCreateMcp'; export { useDevServer } from './useDevServer'; export { useLogsStream } from './useLogsStream'; export { useProject } from './useProject'; diff --git a/src/cli/tui/hooks/useCreateABTest.ts b/src/cli/tui/hooks/useCreateABTest.ts deleted file mode 100644 index 89c36715e..000000000 --- a/src/cli/tui/hooks/useCreateABTest.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { AddTargetBasedABTestOptions } from '../../primitives/ABTestPrimitive'; -import { abTestPrimitive } from '../../primitives/registry'; -import type { GatewayChoice } from '../screens/ab-test/types'; -import { useCallback, useEffect, useState } from 'react'; - -interface CreateABTestConfig { - name: string; - description?: string; - agent: string; - gatewayChoice?: GatewayChoice; - controlBundle: string; - controlVersion: string; - treatmentBundle: string; - treatmentVersion: string; - controlWeight: number; - treatmentWeight: number; - onlineEval: string; - maxDuration?: number; - enableOnCreate?: boolean; -} - -export function useCreateABTest() { - const [status, setStatus] = useState<{ state: 'idle' | 'loading' | 'success' | 'error'; error?: string }>({ - state: 'idle', - }); - - const create = useCallback(async (config: CreateABTestConfig) => { - setStatus({ state: 'loading' }); - try { - const addResult = await abTestPrimitive.add({ - name: config.name, - description: config.description, - agent: config.agent, - gatewayChoice: config.gatewayChoice, - controlBundle: config.controlBundle, - controlVersion: config.controlVersion, - treatmentBundle: config.treatmentBundle, - treatmentVersion: config.treatmentVersion, - controlWeight: config.controlWeight, - treatmentWeight: config.treatmentWeight, - onlineEval: config.onlineEval, - maxDurationDays: config.maxDuration, - enableOnCreate: config.enableOnCreate, - }); - if (!addResult.success) { - throw new Error(addResult.error?.message ?? 'Failed to create AB test'); - } - setStatus({ state: 'success' }); - return { ok: true as const, testName: config.name }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to create AB test.'; - setStatus({ state: 'error', error: message }); - return { ok: false as const, error: message }; - } - }, []); - - const createTargetBased = useCallback(async (config: Omit) => { - setStatus({ state: 'loading' }); - try { - const addResult = await abTestPrimitive.addTargetBased(config); - if (!addResult.success) { - throw new Error(addResult.error?.message ?? 'Failed to create target-based AB test'); - } - setStatus({ state: 'success' }); - return { ok: true as const, testName: config.name }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to create target-based AB test.'; - setStatus({ state: 'error', error: message }); - return { ok: false as const, error: message }; - } - }, []); - - const reset = useCallback(() => { - setStatus({ state: 'idle' }); - }, []); - - return { status, createABTest: create, createTargetBasedABTest: createTargetBased, reset }; -} - -export function useExistingABTestNames() { - const [names, setNames] = useState([]); - - useEffect(() => { - void abTestPrimitive.getAllNames().then(setNames); - }, []); - - const refresh = useCallback(async () => { - const result = await abTestPrimitive.getAllNames(); - setNames(result); - }, []); - - return { names, refresh }; -} diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index db541c76e..fd9a4adce 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -2,6 +2,7 @@ import { agentPrimitive, gatewayPrimitive, gatewayTargetPrimitive, + knowledgeBasePrimitive, policyEnginePrimitive, } from '../../primitives/registry'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; @@ -91,6 +92,56 @@ export function useExistingGateways() { return { gateways, refresh }; } +export function useMcpGatewayNames() { + const [mcpGateways, setMcpGateways] = useState([]); + + useEffect(() => { + async function load() { + const result = await gatewayPrimitive.getMcpGatewayNames(); + setMcpGateways(result); + } + void load(); + }, []); + + return { mcpGateways }; +} + +export function useExistingRuntimeNames() { + const [runtimeNames, setRuntimeNames] = useState([]); + + useEffect(() => { + async function load() { + const result = await gatewayPrimitive.getRuntimeNames(); + setRuntimeNames(result); + } + void load(); + }, []); + + return { runtimeNames }; +} + +export function useRuntimeEndpoints(runtimeName: string | undefined) { + const [endpoints, setEndpoints] = useState<{ name: string; version: number }[]>([]); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + async function load() { + if (!runtimeName) { + setEndpoints([]); + setLoaded(false); + return; + } + setLoaded(false); + const result = await gatewayPrimitive.getRuntimeEndpoints(runtimeName); + setEndpoints(result); + setLoaded(true); + } + void load(); + }, [runtimeName]); + + return { endpoints, loaded }; +} + export function useExistingPolicyEngines() { const [engines, setEngines] = useState([]); @@ -156,6 +207,33 @@ export function useExistingToolNames() { return { toolNames, refresh }; } +export function useExistingKnowledgeBases() { + const [knowledgeBases, setKnowledgeBases] = useState([]); + + useEffect(() => { + async function load() { + try { + const removable = await knowledgeBasePrimitive.getRemovable(); + setKnowledgeBases(removable.map(kb => kb.name)); + } catch { + setKnowledgeBases([]); + } + } + void load(); + }, []); + + const refresh = useCallback(async () => { + try { + const removable = await knowledgeBasePrimitive.getRemovable(); + setKnowledgeBases(removable.map(kb => kb.name)); + } catch { + setKnowledgeBases([]); + } + }, []); + + return { knowledgeBases, refresh }; +} + export function useUnassignedTargets() { const [targets, setTargets] = useState([]); diff --git a/src/cli/tui/hooks/useCreateOnlineEval.ts b/src/cli/tui/hooks/useCreateOnlineEval.ts index 0fa53f56b..c9204fbf7 100644 --- a/src/cli/tui/hooks/useCreateOnlineEval.ts +++ b/src/cli/tui/hooks/useCreateOnlineEval.ts @@ -6,6 +6,8 @@ interface CreateOnlineEvalConfig { name: string; agent: string; endpoint?: string; + logGroupNames?: string[]; + serviceNames?: string[]; evaluators: string[]; samplingRate: number; sessionTimeoutMinutes?: number; @@ -31,6 +33,8 @@ export function useCreateOnlineEval() { name: config.name, agent: config.agent, ...(config.endpoint ? { endpoint: config.endpoint } : {}), + ...(config.logGroupNames ? { logGroupNames: config.logGroupNames } : {}), + ...(config.serviceNames ? { serviceNames: config.serviceNames } : {}), evaluators: config.evaluators, samplingRate: config.samplingRate, ...(config.sessionTimeoutMinutes !== undefined && { sessionTimeoutMinutes: config.sessionTimeoutMinutes }), diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index 80114cceb..17d0b969d 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -124,7 +124,7 @@ export function useDevServer(options: { setConfigLoaded(true); }; void load(); - }, [options.workingDir]); + }, [options.workingDir, options.agentName]); const config: DevConfig | null = useMemo(() => { if (!project || !options.agentName) { diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index 3825c00c0..b8177c632 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -3,11 +3,11 @@ import type { ResourceType } from '../../commands/remove/types'; import { RemoveLogger } from '../../logging'; import type { RemovableGatewayTarget, RemovalPreview } from '../../operations/remove'; import type { RemovableCredential } from '../../primitives/CredentialPrimitive'; +import type { RemovableKnowledgeBase } from '../../primitives/KnowledgeBasePrimitive'; import type { RemovableMemory } from '../../primitives/MemoryPrimitive'; import type { RemovablePolicyResource } from '../../primitives/PolicyPrimitive'; import type { RemovableRuntimeEndpoint } from '../../primitives/RuntimeEndpointPrimitive'; import { - abTestPrimitive, agentPrimitive, configBundlePrimitive, credentialPrimitive, @@ -16,6 +16,7 @@ import { gatewayPrimitive, gatewayTargetPrimitive, harnessPrimitive, + knowledgeBasePrimitive, memoryPrimitive, onlineEvalConfigPrimitive, paymentConnectorPrimitive, @@ -33,6 +34,7 @@ export type { RemovableMemory, RemovableCredential as RemovableIdentity, RemovableGatewayTarget, + RemovableKnowledgeBase, RemovablePolicyResource, RemovableRuntimeEndpoint, }; @@ -160,6 +162,11 @@ export function useRemovableDatasets() { return { datasets, ...rest }; } +export function useRemovableKnowledgeBases() { + const { items: knowledgeBases, ...rest } = useRemovableResources(() => knowledgeBasePrimitive.getRemovable()); + return { knowledgeBases, ...rest }; +} + export function useRemovableOnlineEvalConfigs() { const { items: onlineEvalConfigs, ...rest } = useRemovableResources(() => onlineEvalConfigPrimitive.getRemovable()); return { onlineEvalConfigs, ...rest }; @@ -180,19 +187,6 @@ export function useRemovableConfigBundles() { return { configBundles, ...rest }; } -export function useRemovableABTests() { - const { items: abTests, ...rest } = useRemovableResources(() => abTestPrimitive.getRemovable()); - return { abTests, ...rest }; -} - -export function useRemoveABTest() { - return useRemoveResource( - (name: string) => abTestPrimitive.remove(name), - 'ab-test', - name => name - ); -} - export function useRemovableRuntimeEndpoints() { const { items: endpoints, ...rest } = useRemovableResources(() => runtimeEndpointPrimitive.getRemovable() @@ -277,6 +271,10 @@ export function useRemovalPreview() { (name: string) => loadPreview(n => datasetPrimitive.previewRemove(n), name), [loadPreview] ); + const loadKnowledgeBasePreview = useCallback( + (name: string) => loadPreview(n => knowledgeBasePrimitive.previewRemove(n), name), + [loadPreview] + ); const loadOnlineEvalPreview = useCallback( (name: string) => loadPreview(n => onlineEvalConfigPrimitive.previewRemove(n), name), [loadPreview] @@ -294,11 +292,6 @@ export function useRemovalPreview() { [loadPreview] ); - const loadABTestPreview = useCallback( - (name: string) => loadPreview(n => abTestPrimitive.previewRemove(n), name), - [loadPreview] - ); - const loadRuntimeEndpointPreview = useCallback( (name: string) => loadPreview(n => runtimeEndpointPrimitive.previewRemove(n), name), [loadPreview] @@ -318,11 +311,11 @@ export function useRemovalPreview() { loadIdentityPreview, loadEvaluatorPreview, loadDatasetPreview, + loadKnowledgeBasePreview, loadOnlineEvalPreview, loadPolicyEnginePreview, loadPolicyPreview, loadConfigBundlePreview, - loadABTestPreview, loadRuntimeEndpointPreview, reset, }; @@ -403,6 +396,14 @@ export function useRemoveDataset() { ); } +export function useRemoveKnowledgeBase() { + return useRemoveResource( + (name: string) => knowledgeBasePrimitive.remove(name), + 'knowledge-base', + name => name + ); +} + export function useRemovePolicyEngine() { return useRemoveResource( (name: string) => policyEnginePrimitive.remove(name), diff --git a/src/cli/tui/render.ts b/src/cli/tui/render.ts index 89142c7bb..8a2f91434 100644 --- a/src/cli/tui/render.ts +++ b/src/cli/tui/render.ts @@ -118,10 +118,13 @@ export async function renderTUI(options: RenderTUIOptions = {}) { */ export function setupAltScreenCleanup() { const cleanup = () => { - if (!process.stdout.isTTY) return; - if (inAltScreen) { - process.stdout.write(EXIT_ALT_SCREEN); + // Only emit terminal control sequences if we actually entered the alt screen (i.e. a TUI ran). + // Plain CLI/JSON commands never hid the cursor, so writing SHOW_CURSOR here would leak the + // `\x1b[?25h` escape into stdout and corrupt piped/redirected output (e.g. `... --json | jq`). + if (!inAltScreen) { + return; } + process.stdout.write(EXIT_ALT_SCREEN); process.stdout.write(SHOW_CURSOR); }; diff --git a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx b/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx deleted file mode 100644 index ddf2cbb72..000000000 --- a/src/cli/tui/screens/ab-test/ABTestDetailScreen.tsx +++ /dev/null @@ -1,637 +0,0 @@ -import { ConfigIO } from '../../../../lib'; -import { getCredentialProvider } from '../../../aws/account'; -import { getABTest, updateABTest } from '../../../aws/agentcore-ab-tests'; -import type { GetABTestResult } from '../../../aws/agentcore-ab-tests'; -import { getOnlineEvaluationConfig } from '../../../aws/agentcore-control'; -import { getHttpGateway, listHttpGatewayTargets } from '../../../aws/agentcore-http-gateways'; -import { dnsSuffix } from '../../../aws/partition'; -import { getErrorMessage } from '../../../errors'; -import { GradientText, Screen } from '../../components'; -import { CloudWatchLogsClient, FilterLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs'; -import type { FilterLogEventsCommandInput, FilteredLogEvent } from '@aws-sdk/client-cloudwatch-logs'; -import { Box, Text, useInput } from 'ink'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; - -interface ABTestDetailScreenProps { - abTestId: string; - region: string; - onExit: () => void; -} - -/** Derive the gateway URL from a gateway ARN. */ -function gatewayUrlFromArn(arn: string): string { - const parts = arn.split(':'); - const region = parts[3]; - const gatewayId = parts[5]?.split('/')[1]; - if (region && gatewayId) { - return `https://${gatewayId}.gateway.bedrock-agentcore.${region}.${dnsSuffix(region)}`; - } - return arn; -} - -/** Extract the resource ID from an ARN (last segment after / or :). */ -function extractId(arn: string): string { - const slashIdx = arn.lastIndexOf('/'); - if (slashIdx !== -1) return arn.slice(slashIdx + 1); - const colonIdx = arn.lastIndexOf(':'); - if (colonIdx !== -1) return arn.slice(colonIdx + 1); - return arn; -} - -/** Truncate a version ID to 8 characters. */ -function shortVersion(version: string): string { - return version.slice(0, 8); -} - -/** Format a Unix epoch timestamp (seconds) to a UTC date string. */ -function formatTimestamp(ts: string | number): string { - const ms = typeof ts === 'string' ? parseFloat(ts) * 1000 : ts * 1000; - const d = new Date(ms); - return d - .toISOString() - .replace('T', ' ') - .replace(/\.\d+Z$/, ' UTC'); -} - -/** Build a horizontal rule with optional left label and right label. */ -function rule(left?: string, right?: string, width = 48): string { - if (!left && !right) return '─'.repeat(width); - const leftPart = left ? `── ${left} ` : '──'; - const rightPart = right ? ` ${right} ──` : ''; - const fillLen = width - leftPart.length - rightPart.length; - const fill = fillLen > 0 ? '─'.repeat(fillLen) : ''; - return `${leftPart}${fill}${rightPart}`; -} - -interface DebugCheckResult { - label: string; - status: 'pass' | 'fail' | 'warn'; - detail: string; -} - -async function runDebugChecks(test: GetABTestResult, region: string): Promise { - const results: DebugCheckResult[] = []; - const logsClient = new CloudWatchLogsClient({ region, credentials: getCredentialProvider() }); - - // 1. AB Test Status - results.push({ - label: 'AB Test Status', - status: test.status === 'ACTIVE' && test.executionStatus === 'RUNNING' ? 'pass' : 'warn', - detail: `${test.status} / ${test.executionStatus}`, - }); - - // 1b. AB Test Role - results.push({ - label: 'AB Test Role', - status: test.roleArn ? 'pass' : 'warn', - detail: test.roleArn ?? 'No role ARN', - }); - - // 2. Online Eval Config(s) - const evalConfigArns: { name: string; arn: string }[] = - 'perVariantOnlineEvaluationConfig' in test.evaluationConfig - ? test.evaluationConfig.perVariantOnlineEvaluationConfig.map(v => ({ - name: v.name, - arn: v.onlineEvaluationConfigArn, - })) - : [{ name: '', arn: test.evaluationConfig.onlineEvaluationConfigArn }]; - - for (const { name: variantName, arn: evalArn } of evalConfigArns) { - const evalConfigId = extractId(evalArn); - const labelSuffix = variantName ? ` (${variantName})` : ''; - try { - const evalConfig = await getOnlineEvaluationConfig({ region, configId: evalConfigId }); - results.push({ - label: `Online Eval Config${labelSuffix}`, - status: evalConfig.executionStatus === 'ENABLED' ? 'pass' : 'fail', - detail: `${evalConfig.configName} — ${evalConfig.executionStatus}`, - }); - } catch (err) { - results.push({ label: `Online Eval Config${labelSuffix}`, status: 'fail', detail: getErrorMessage(err) }); - } - } - - // 2b. Gateway Role - const gatewayId = extractId(test.gatewayArn); - try { - const gateway = await getHttpGateway({ region, gatewayId }); - results.push({ - label: 'Gateway Role', - status: gateway.roleArn ? 'pass' : 'warn', - detail: gateway.roleArn ?? 'No role ARN', - }); - } catch (err) { - results.push({ label: 'Gateway Role', status: 'fail', detail: getErrorMessage(err) }); - } - - // 5. Runtime spans — check for experiment metadata per variant in aws/spans and runtime log group - // service.name in spans follows the pattern: {projectName}_{agentName}.{endpoint} - // We derive the service name prefix from the deployed state runtimeId (strip random suffix). - const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; - const variantNames = test.variants.map(v => v.name); - let serviceNamePrefix: string | undefined; - let runtimeId: string | undefined; - try { - const configIO = new ConfigIO(); - const deployedState = await configIO.readDeployedState(); - for (const [, target] of Object.entries(deployedState.targets ?? {})) { - const runtimes = target.resources?.runtimes ?? {}; - const firstRuntime = Object.values(runtimes)[0]; - if (firstRuntime?.runtimeId) { - runtimeId = firstRuntime.runtimeId; - serviceNamePrefix = runtimeId.replace(/-[^-]+$/, ''); - break; - } - } - } catch { - // Fall back to abTestArn-only filtering if deployed state isn't readable - } - - const runtimeLogGroupName = runtimeId ? `/aws/bedrock-agentcore/runtimes/${runtimeId}-DEFAULT` : undefined; - const logGroupsToQuery = ['aws/spans']; - if (runtimeLogGroupName) { - logGroupsToQuery.push(runtimeLogGroupName); - } - - const queryAllGroups = (params: Omit) => - Promise.all( - logGroupsToQuery.map(lg => - logsClient - .send(new FilterLogEventsCommand({ ...params, logGroupName: lg })) - .catch(() => ({ events: [] as FilteredLogEvent[] })) - ) - ); - - try { - const baseFilter = serviceNamePrefix ? `"${serviceNamePrefix}"` : '"gen_ai_agent"'; - - const runtimeSpanResults = await queryAllGroups({ - startTime: twoHoursAgo, - filterPattern: baseFilter, - limit: 1, - }); - const hasRuntimeSpans = runtimeSpanResults.some(r => (r.events?.length ?? 0) > 0); - - const variantSpanCounts = await Promise.all( - variantNames.map(async name => { - const results = await queryAllGroups({ - startTime: twoHoursAgo, - filterPattern: `"${test.abTestArn}" "${name}"`, - limit: 50, - }); - return results.reduce((sum, r) => sum + (r.events?.length ?? 0), 0); - }) - ); - - const totalExperimentSpans = variantSpanCounts.reduce((sum, count) => sum + count, 0); - - for (let i = 0; i < variantNames.length; i++) { - const name = variantNames[i]; - const count = variantSpanCounts[i] ?? 0; - const label = `Runtime Experiment Spans — ${name} (2h)`; - - if (count > 0) { - results.push({ label, status: 'pass', detail: `${count} spans with experiment metadata` }); - } else if (hasRuntimeSpans) { - results.push({ - label, - status: 'warn', - detail: - totalExperimentSpans > 0 - ? `No spans for ${name} — traffic may not be reaching this variant` - : 'Runtime spans found but no experiment metadata — update bedrock-agentcore SDK to the latest version', - }); - } else { - results.push({ label, status: 'warn', detail: 'No runtime spans found — send traffic to the gateway first' }); - } - } - } catch (err) { - results.push({ label: 'Runtime Experiment Spans', status: 'fail', detail: getErrorMessage(err) }); - } - - // 6. Eval Results — check each eval config's log group - const thirtyMinAgo = Date.now() - 30 * 60 * 1000; - for (const { name: variantName, arn: evalArn } of evalConfigArns) { - const configId = extractId(evalArn); - const labelSuffix = variantName ? ` (${variantName})` : ''; - try { - const evalLogGroup = `/aws/bedrock-agentcore/evaluations/results/${configId}`; - - const [allEvents, taggedEvents] = await Promise.all([ - logsClient.send(new FilterLogEventsCommand({ logGroupName: evalLogGroup, startTime: thirtyMinAgo, limit: 1 })), - logsClient.send( - new FilterLogEventsCommand({ - logGroupName: evalLogGroup, - startTime: thirtyMinAgo, - filterPattern: `"${test.abTestArn}"`, - limit: 100, - }) - ), - ]); - - const hasResults = (allEvents.events?.length ?? 0) > 0; - const taggedCount = taggedEvents.events?.length ?? 0; - - if (!hasResults) { - results.push({ - label: `Eval Results${labelSuffix}`, - status: 'warn', - detail: 'No eval results yet — wait ~5m after session timeout for evaluator to process', - }); - } else { - results.push({ - label: `Eval Results${labelSuffix}`, - status: taggedCount > 0 ? 'pass' : 'warn', - detail: - taggedCount > 0 - ? `${taggedCount} results tagged with AB test` - : 'Results exist but none tagged with variant — check gateway trace delivery', - }); - } - } catch (err) { - const msg = getErrorMessage(err); - results.push({ - label: `Eval Results${labelSuffix}`, - status: msg.includes('ResourceNotFoundException') ? 'warn' : 'fail', - detail: msg.includes('ResourceNotFoundException') ? 'Log group not found — evaluator has not run yet' : msg, - }); - } - } - - // 6. Aggregation Results - const metrics = test.results?.evaluatorMetrics ?? []; - const reporting = metrics.filter(m => m.controlStats?.sampleSize > 0); - results.push({ - label: 'Aggregation Results', - status: reporting.length > 0 ? 'pass' : 'warn', - detail: - reporting.length > 0 - ? `${reporting.length} evaluator(s) reporting` - : 'No aggregation data yet — wait ~12-15m after traffic', - }); - - return results; -} - -export function ABTestDetailScreen({ abTestId, region, onExit }: ABTestDetailScreenProps) { - const [test, setTest] = useState(null); - const [error, setError] = useState(null); - const [actionMessage, setActionMessage] = useState(null); - const [confirmingStop, setConfirmingStop] = useState(false); - const [confirmingPromote, setConfirmingPromote] = useState(false); - const [debugResults, setDebugResults] = useState(null); - const [debugLoading, setDebugLoading] = useState(false); - const [targetName, setTargetName] = useState(''); - - const hasFetched = useRef(false); - useEffect(() => { - if (hasFetched.current) return; - hasFetched.current = true; - const load = async () => { - try { - const result = await getABTest({ region, abTestId }); - setTest(result); - - // Fetch gateway target name for invocation URL - const gwId = extractId(result.gatewayArn); - try { - const targets = await listHttpGatewayTargets({ region, gatewayId: gwId, maxResults: 1 }); - const firstTarget = targets.targets[0]; - if (firstTarget) setTargetName(firstTarget.name); - } catch { - // Best-effort — URL will show without target path - } - } catch (err) { - setError(getErrorMessage(err)); - } - }; - void load(); - }, [region, abTestId]); - - const performAction = useCallback( - async (targetStatus: 'PAUSED' | 'RUNNING' | 'STOPPED', label: string) => { - setActionMessage(`${label}...`); - try { - await updateABTest({ region, abTestId, executionStatus: targetStatus }); - // Poll until status updates or max attempts reached - for (let i = 0; i < 5; i++) { - await new Promise(resolve => setTimeout(resolve, 1000)); - const result = await getABTest({ region, abTestId }); - setTest(result); - if (result.executionStatus === targetStatus) { - setActionMessage(label.replace('...', 'd').replace('ing', 'ed')); - return; - } - } - // Final fetch even if status didn't converge - setActionMessage(label.replace('ing', 'ed')); - } catch (err: unknown) { - setActionMessage(`Error: ${getErrorMessage(err)}`); - } - }, - [region, abTestId] - ); - - useInput((input, _key) => { - if (!test) return; - - if (confirmingStop) { - if (input === 'y' || input === 'Y') { - setConfirmingStop(false); - void performAction('STOPPED', 'Stopping'); - } else { - setConfirmingStop(false); - } - return; - } - - if (confirmingPromote) { - if (input === 'y' || input === 'Y') { - setConfirmingPromote(false); - setActionMessage('Promoting...'); - void (async () => { - try { - // Stop the AB test - await updateABTest({ region, abTestId, executionStatus: 'STOPPED' }); - for (let i = 0; i < 5; i++) { - await new Promise(resolve => setTimeout(resolve, 1000)); - const result = await getABTest({ region, abTestId }); - setTest(result); - if (result.executionStatus === 'STOPPED') break; - } - - // Apply promotion to agentcore.json - let promotionDetail = ''; - try { - const { promoteABTestConfig } = await import('../../../operations/ab-test/promote'); - const promoResult = await promoteABTestConfig(abTestId, test.name); - promotionDetail = promoResult.promoted - ? `${promoResult.promotionDetail} Run \`agentcore deploy\` to apply.` - : promoResult.promotionDetail; - } catch { - // Config update failed — still report the stop - } - - setActionMessage(promotionDetail || 'AB test stopped. Run `agentcore deploy` to apply.'); - } catch (err) { - setActionMessage(`Error: ${getErrorMessage(err)}`); - } - })(); - } else { - setConfirmingPromote(false); - } - return; - } - - if (input === 'p' || input === 'P') { - void performAction('PAUSED', 'Pausing'); - } - - if (input === 'r' || input === 'R') { - void performAction('RUNNING', 'Resuming'); - } - - if (input === 's' || input === 'S') { - setConfirmingStop(true); - setActionMessage(null); - } - - if (input === 'w' || input === 'W') { - setConfirmingPromote(true); - setActionMessage(null); - } - - if (input === 'd' || input === 'D') { - setDebugLoading(true); - setDebugResults(null); - void runDebugChecks(test, region) - .then(results => { - setDebugResults(results); - setDebugLoading(false); - }) - .catch(() => { - setDebugResults([{ label: 'Debug', status: 'fail' as const, detail: 'Diagnostics failed to run' }]); - setDebugLoading(false); - }); - } - }); - - if (error) { - return ( - - {`Error: ${error}`} - - ); - } - - if (!test) { - return ( - - Loading... - - ); - } - - const controlVariant = test.variants.find(v => v.name === 'C'); - const treatmentVariant = test.variants.find(v => v.name === 'T1'); - - const executionColor = - test.executionStatus === 'RUNNING' ? 'green' : test.executionStatus === 'PAUSED' ? 'yellow' : 'red'; - - const helpParts: string[] = []; - if (test.executionStatus === 'RUNNING') { - helpParts.push('P pause', 'S stop', 'W promote'); - } else if (test.executionStatus === 'PAUSED') { - helpParts.push('R resume', 'S stop', 'W promote'); - } - helpParts.push('D debug', 'Esc exit'); - const helpKeys = helpParts.join(' · '); - - // Build status text: only show provisioning status if not ACTIVE - const statusPrefix = test.status !== 'ACTIVE' ? `${test.status} ` : ''; - - // TODO(post-preview): Re-enable duration display once configurable duration is launched. - const durationText = ''; - - // Column width for side-by-side variants - const colW = 28; - - return ( - - - {/* ── Header: Line 1 — status ─────────────────────────── */} - - - {statusPrefix && {statusPrefix}} - {`● ${test.executionStatus}`} - - {durationText && {durationText}} - - - {/* ── Header: Line 2 — invocation URL ────────────────────── */} - {targetName ? ( - - {`Invocation URL: ${gatewayUrlFromArn(test.gatewayArn)}/${targetName}/invocations`} - - ) : ( - - Invocation URL: loading... - - )} - - {/* ── Header: Line 3 — online eval (only for single-config mode) ── */} - {'onlineEvaluationConfigArn' in test.evaluationConfig && ( - - {`Online Eval: ${extractId(test.evaluationConfig.onlineEvaluationConfigArn)}`} - - )} - - {/* ── Description (if present) ────────────────────────── */} - {test.description && ( - - {`Description: ${test.description}`} - - )} - - {/* ── Variants: side-by-side ──────────────────────────── */} - - - {'CONTROL (C)'} - {`${String(controlVariant?.weight ?? 'N/A')}% traffic`} - - {controlVariant?.variantConfiguration.target - ? `target: ${controlVariant.variantConfiguration.target.name}` - : `${extractId(controlVariant?.variantConfiguration.configurationBundle?.bundleArn ?? '')} @ ${shortVersion(controlVariant?.variantConfiguration.configurationBundle?.bundleVersion ?? '')}`} - - - - {'TREATMENT (T1)'} - {`${String(treatmentVariant?.weight ?? 'N/A')}% traffic`} - - {treatmentVariant?.variantConfiguration.target - ? `target: ${treatmentVariant.variantConfiguration.target.name}` - : `${extractId(treatmentVariant?.variantConfiguration.configurationBundle?.bundleArn ?? '')} @ ${shortVersion(treatmentVariant?.variantConfiguration.configurationBundle?.bundleVersion ?? '')}`} - - - - - {/* ── Evaluation Results ───────────────────────────────── */} - - {test.results ? ( - <> - - {rule( - 'Results', - test.results.analysisTimestamp ? formatTimestamp(test.results.analysisTimestamp) : undefined - )} - - - - {''} - - - {'Control'} - - - {'Treatment'} - - {'Δ'} - - {test.results.evaluatorMetrics.map((metric, i) => ( - 0 ? 1 : 0}> - - - {extractId(metric.evaluatorArn)} - - - {metric.controlStats.mean.toFixed(4)} - - - {metric.variantResults[0]?.mean.toFixed(4) ?? ''} - - {metric.variantResults[0]?.isSignificant ? ( - {`+${(metric.variantResults[0]?.percentChange ?? 0).toFixed(2)}% ✓`} - ) : ( - {`${(metric.variantResults[0]?.percentChange ?? 0).toFixed(2)}% ✗`} - )} - - - - {''} - - - {`n=${metric.controlStats.sampleSize}`} - - - {`n=${metric.variantResults[0]?.sampleSize ?? ''}`} - - {`p=${metric.variantResults[0]?.pValue?.toFixed(3) ?? 'N/A'}`} - - - ))} - - ) : ( - <> - {rule('Results')} - - No evaluation results yet. - - - )} - - - {/* ── Debug Panel ─────────────────────────────────────── */} - {debugLoading && ( - - - - )} - {debugResults && ( - - {rule('Pipeline Debug')} - {debugResults.map((check, i) => { - const icon = check.status === 'pass' ? '✓' : check.status === 'fail' ? '✗' : '⚠'; - const color = check.status === 'pass' ? 'green' : check.status === 'fail' ? 'red' : 'yellow'; - return ( - - {` ${icon} `} - {check.label} - {` ${check.detail}`} - - ); - })} - - )} - - {/* ── Stop confirmation ────────────────────────────────── */} - {confirmingStop && ( - - - { - 'Stop this AB test permanently? All traffic will shift to the control variant. This cannot be undone. (Y/n)' - } - - - )} - - {/* ── Promote confirmation ─────────────────────────────── */} - {confirmingPromote && ( - - - { - 'Promote treatment as winner? This will stop the AB test and update the control endpoint to the treatment version. Run `agentcore deploy` after to apply. (Y/n)' - } - - - )} - - {/* ── Action feedback ──────────────────────────────────── */} - {actionMessage && !confirmingStop && ( - - {actionMessage} - - )} - - - ); -} diff --git a/src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx b/src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx deleted file mode 100644 index 9d47e4441..000000000 --- a/src/cli/tui/screens/ab-test/ABTestPickerScreen.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { ConfigIO } from '../../../../lib'; -import type { SelectableItem } from '../../components'; -import { Screen, SelectScreen } from '../../components'; -import { HELP_TEXT } from '../../constants'; -import { ABTestDetailScreen } from './ABTestDetailScreen'; -import { Text } from 'ink'; -import React, { useEffect, useRef, useState } from 'react'; - -interface ABTestPickerScreenProps { - onExit: () => void; -} - -interface DeployedABTest { - name: string; - abTestId: string; -} - -export function ABTestPickerScreen({ onExit }: ABTestPickerScreenProps) { - const [tests, setTests] = useState(null); - const [selectedTest, setSelectedTest] = useState(null); - const [region, setRegion] = useState('us-east-1'); - - const hasFetched = useRef(false); - useEffect(() => { - if (hasFetched.current) return; - hasFetched.current = true; - const load = async () => { - try { - const configIO = new ConfigIO(); - const [deployedState, targets] = await Promise.all([ - configIO.readDeployedState(), - configIO.resolveAWSDeploymentTargets(), - ]); - const found: DeployedABTest[] = []; - for (const target of Object.values(deployedState.targets ?? {})) { - const abTests = target.resources?.abTests; - if (abTests) { - for (const [name, state] of Object.entries(abTests)) { - found.push({ name, abTestId: state.abTestId }); - } - } - } - setTests(found); - if (targets.length > 0) setRegion(targets[0]!.region); - } catch { - setTests([]); - } - }; - void load(); - }, []); - - if (selectedTest) { - return setSelectedTest(null)} />; - } - - if (tests === null) { - return ( - - Loading AB tests... - - ); - } - - if (tests.length === 0) { - return ( - - No deployed AB tests found. - Add one with `agentcore add ab-test` and deploy. - - ); - } - - const items: SelectableItem[] = tests.map(t => ({ - id: t.name, - title: t.name, - description: `ID: ${t.abTestId}`, - })); - - return ( - { - const test = tests.find(t => t.name === item.id); - if (test) setSelectedTest(test); - }} - onExit={onExit} - /> - ); -} diff --git a/src/cli/tui/screens/ab-test/AddABTestFlow.tsx b/src/cli/tui/screens/ab-test/AddABTestFlow.tsx deleted file mode 100644 index b8313075d..000000000 --- a/src/cli/tui/screens/ab-test/AddABTestFlow.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { ConfigIO } from '../../../../lib'; -import { listConfigurationBundleVersions } from '../../../aws/agentcore-config-bundles'; -import { ErrorPrompt } from '../../components'; -import { useCreateABTest, useExistingABTestNames } from '../../hooks/useCreateABTest'; -import { AddSuccessScreen } from '../add/AddSuccessScreen'; -import { AddConfigBundleFlow } from '../config-bundle/AddConfigBundleFlow'; -import { AddABTestScreen } from './AddABTestScreen'; -import type { HttpGatewayInfo, OnlineEvalConfigInfo, RuntimeInfo } from './AddABTestScreen'; -import { TargetBasedABTestScreen } from './TargetBasedABTestScreen'; -import type { AddABTestConfig } from './types'; -import React, { useCallback, useEffect, useState } from 'react'; - -type FlowState = - | { name: 'create-wizard' } - | { name: 'target-wizard' } - | { name: 'create-bundle' } - | { name: 'create-success'; testName: string } - | { name: 'error'; message: string }; - -interface AddABTestFlowProps { - isInteractive?: boolean; - onExit: () => void; - onBack: () => void; - onDev?: () => void; - onDeploy?: () => void; -} - -export function AddABTestFlow({ isInteractive = true, onExit, onBack, onDev, onDeploy }: AddABTestFlowProps) { - const { createABTest, createTargetBasedABTest, reset: resetCreate } = useCreateABTest(); - const { names: existingNames } = useExistingABTestNames(); - const [flow, setFlow] = useState({ name: 'create-wizard' }); - - // Load deployed state for bundle lists - const [agents, setAgents] = useState<{ name: string }[]>([]); - const [existingHttpGateways, setExistingHttpGateways] = useState([]); - const [deployedBundles, setDeployedBundles] = useState<{ name: string; bundleId: string }[]>([]); - const [onlineEvalConfigs, setOnlineEvalConfigs] = useState([]); - const [runtimesInfo, setRuntimesInfo] = useState([]); - const [httpGatewayDetails, setHttpGatewayDetails] = useState([]); - const [onlineEvalConfigDetails, setOnlineEvalConfigDetails] = useState([]); - const [region, setRegion] = useState('us-east-1'); - - const [loadEpoch, setLoadEpoch] = useState(0); - - useEffect(() => { - void (async () => { - try { - const configIO = new ConfigIO(); - const deployedState = await configIO.readDeployedState(); - const projectSpec = await configIO.readProjectSpec(); - - // Get region from first target - for (const [, target] of Object.entries(deployedState.targets ?? {})) { - const resources = target.resources; - - // Deployed config bundles - const bundles = resources?.configBundles; - if (bundles) { - setDeployedBundles( - Object.entries(bundles).map(([name, state]) => ({ - name, - bundleId: state.bundleId, - })) - ); - } - break; - } - - // Agents from project spec runtimes - const runtimes = projectSpec.runtimes ?? []; - setAgents(runtimes.map(r => ({ name: r.name }))); - - // Runtimes with endpoints for target-based mode - setRuntimesInfo( - runtimes.map(r => ({ - name: r.name, - endpoints: Object.entries(r.endpoints ?? {}).map(([epName, ep]) => ({ - name: epName, - version: ep.version, - })), - })) - ); - - // Existing HTTP gateways from project spec - const httpGws = projectSpec.httpGateways ?? []; - setExistingHttpGateways(httpGws.map(gw => gw.name)); - - // HTTP gateway details with targets for target-based mode - setHttpGatewayDetails( - httpGws.map(gw => ({ - name: gw.name, - runtimeRef: gw.runtimeRef, - targets: (gw.targets ?? []).map(t => ({ - name: t.name, - runtimeRef: t.runtimeRef, - qualifier: t.qualifier, - })), - })) - ); - - // Online eval configs from project spec - const evalConfigs = projectSpec.onlineEvalConfigs ?? []; - setOnlineEvalConfigs(evalConfigs.map(c => c.name)); - setOnlineEvalConfigDetails( - evalConfigs.map(c => ({ - name: c.name, - agent: c.agent, - endpoint: c.endpoint, - })) - ); - - // Region from aws-targets, falling back to env - const targets = await configIO.resolveAWSDeploymentTargets(); - if (targets.length > 0) { - setRegion(targets[0]!.region); - } else { - setRegion(process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'); - } - } catch { - // No deployed state — lists will be empty - } - })(); - }, [loadEpoch]); - - const fetchBundleVersions = useCallback( - async (bundleId: string) => { - try { - const result = await listConfigurationBundleVersions({ region, bundleId }); - return result.versions.map(v => ({ - versionId: v.versionId, - createdAt: v.versionCreatedAt, - })); - } catch { - return []; - } - }, - [region] - ); - - useEffect(() => { - if (!isInteractive && flow.name === 'create-success') { - onExit(); - } - }, [isInteractive, flow.name, onExit]); - - const handleCreateComplete = useCallback( - (config: AddABTestConfig) => { - if (config.mode === 'target-based') { - const gatewayName = - config.gatewayChoice.type === 'existing-http' - ? config.gatewayChoice.name - : config.gatewayChoice.type === 'create-new' - ? `${config.name.replace(/_/g, '-').slice(0, 44)}-gw` - : ''; - void createTargetBasedABTest({ - name: config.name, - description: config.description || undefined, - gateway: gatewayName, - runtime: config.runtime, - controlEndpoint: config.controlEndpoint, - treatmentEndpoint: config.treatmentEndpoint, - controlWeight: config.controlWeight, - treatmentWeight: config.treatmentWeight, - controlOnlineEval: config.controlOnlineEval, - treatmentOnlineEval: config.treatmentOnlineEval, - enableOnCreate: config.enableOnCreate, - }).then(result => { - if (result.ok) { - setFlow({ name: 'create-success', testName: result.testName }); - return; - } - setFlow({ name: 'error', message: result.error }); - }); - return; - } - - const controlWeight = 100 - config.treatmentWeight; - void createABTest({ - name: config.name, - description: config.description || undefined, - agent: config.agent, - gatewayChoice: config.gatewayChoice, - controlBundle: config.controlBundle, - controlVersion: config.controlVersion, - treatmentBundle: config.treatmentBundle, - treatmentVersion: config.treatmentVersion, - controlWeight, - treatmentWeight: config.treatmentWeight, - onlineEval: config.onlineEval, - maxDuration: config.maxDuration, - enableOnCreate: config.enableOnCreate, - }).then(result => { - if (result.ok) { - setFlow({ name: 'create-success', testName: result.testName }); - return; - } - setFlow({ name: 'error', message: result.error }); - }); - }, - [createABTest, createTargetBasedABTest] - ); - - const handleSwitchToTargetBased = useCallback(() => { - setFlow({ name: 'target-wizard' }); - }, []); - - const handleCreateBundle = useCallback(() => { - setFlow({ name: 'create-bundle' }); - }, []); - - const handleBundleFlowDone = useCallback(() => { - setLoadEpoch(e => e + 1); - setFlow({ name: 'create-wizard' }); - }, []); - - if (flow.name === 'create-bundle') { - return ( - - ); - } - - if (flow.name === 'target-wizard') { - return ( - - ); - } - - if (flow.name === 'create-wizard') { - return ( - - ); - } - - if (flow.name === 'create-success') { - return ( - - ); - } - - return ( - { - resetCreate(); - setFlow({ name: 'create-wizard' }); - }} - onExit={onExit} - /> - ); -} diff --git a/src/cli/tui/screens/ab-test/AddABTestScreen.tsx b/src/cli/tui/screens/ab-test/AddABTestScreen.tsx deleted file mode 100644 index 3306ce86c..000000000 --- a/src/cli/tui/screens/ab-test/AddABTestScreen.tsx +++ /dev/null @@ -1,914 +0,0 @@ -import { ABTestNameSchema } from '../../../../schema/schemas/primitives/ab-test'; -import type { SelectableItem } from '../../components'; -import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; -import { HELP_TEXT } from '../../constants'; -import { useListNavigation } from '../../hooks'; -import type { VersionLoadState } from './VariantConfigForm'; -import { VariantConfigForm } from './VariantConfigForm'; -import type { AddABTestConfig, TargetInfo } from './types'; -import { AB_TEST_STEP_LABELS } from './types'; -import { useAddABTestWizard } from './useAddABTestWizard'; -import { Box, Text } from 'ink'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; - -function formatVersionDate(value: string): string { - const n = Number(value); - if (!isNaN(n) && n > 0) { - // Epoch seconds (< 1e12) vs milliseconds (>= 1e12) - const ms = n < 1e12 ? n * 1000 : n; - return new Date(ms).toLocaleString(); - } - return new Date(value).toLocaleString(); -} - -/** Runtime endpoint info passed from the parent flow. */ -export interface RuntimeEndpointInfo { - name: string; - version: number; -} - -/** Runtime info with endpoints, passed from the parent flow. */ -export interface RuntimeInfo { - name: string; - endpoints: RuntimeEndpointInfo[]; -} - -/** Gateway target info passed from the parent flow. */ -export interface GatewayTargetInfo { - name: string; - runtimeRef: string; - qualifier: string; -} - -/** HTTP gateway info with targets, passed from the parent flow. */ -export interface HttpGatewayInfo { - name: string; - runtimeRef: string; - targets: GatewayTargetInfo[]; -} - -/** Online eval config info with agent and endpoint for filtering. */ -export interface OnlineEvalConfigInfo { - name: string; - agent: string; - endpoint?: string; -} - -interface AddABTestScreenProps { - onComplete: (config: AddABTestConfig) => void; - onExit: () => void; - existingTestNames: string[]; - agents: { name: string }[]; - existingHttpGateways: string[]; - deployedBundles: { name: string; bundleId: string }[]; - onlineEvalConfigs: string[]; - fetchBundleVersions: (bundleId: string) => Promise<{ versionId: string; createdAt: string }[]>; - onCreateBundle?: () => void; - /** Full runtime info including endpoints (for target-based mode). */ - runtimes: RuntimeInfo[]; - /** Full HTTP gateway info including targets (for target-based mode). */ - httpGatewayDetails: HttpGatewayInfo[]; - /** Full online eval config objects for target-based eval filtering. */ - onlineEvalConfigDetails?: OnlineEvalConfigInfo[]; - /** Callback to switch to the dedicated target-based wizard screen. */ - onSwitchToTargetBased?: () => void; -} - -export function AddABTestScreen({ - onComplete, - onExit, - existingTestNames, - agents, - existingHttpGateways, - deployedBundles, - onlineEvalConfigs, - fetchBundleVersions, - onCreateBundle, - runtimes, - httpGatewayDetails, - onlineEvalConfigDetails = [], - onSwitchToTargetBased, -}: AddABTestScreenProps) { - const wizard = useAddABTestWizard(); - - // Build select items - const agentItems: SelectableItem[] = useMemo( - () => agents.map(a => ({ id: a.name, title: a.name, description: 'Agent' })), - [agents] - ); - - const bundleItems: SelectableItem[] = useMemo( - () => deployedBundles.map(b => ({ id: b.name, title: b.name, description: `ID: ${b.bundleId}` })), - [deployedBundles] - ); - - const onlineEvalItems: SelectableItem[] = useMemo( - () => onlineEvalConfigs.map(name => ({ id: name, title: name, description: 'Online Eval Config' })), - [onlineEvalConfigs] - ); - - const gatewayItems: SelectableItem[] = useMemo(() => { - const items: SelectableItem[] = []; - for (const gwName of existingHttpGateways) { - items.push({ id: gwName, title: gwName, description: 'Existing HTTP gateway' }); - } - items.push({ - id: '__create__', - title: '+ Create new gateway', - description: 'Auto-create for this AB test', - spaceBefore: items.length > 0, - }); - return items; - }, [existingHttpGateways]); - - const enableItems: SelectableItem[] = useMemo( - () => [ - { id: 'yes', title: 'Yes', description: 'Start the AB test immediately after deploy' }, - { id: 'no', title: 'No', description: 'Create paused — start manually later' }, - ], - [] - ); - - // Version items — fetched dynamically per bundle - const [controlVersionItems, setControlVersionItems] = React.useState([]); - const [treatmentVersionItems, setTreatmentVersionItems] = React.useState([]); - const [controlVersionLoadState, setControlVersionLoadState] = React.useState('idle'); - const [treatmentVersionLoadState, setTreatmentVersionLoadState] = React.useState('idle'); - - const handleFetchVersions = React.useCallback( - (bundleName: string) => { - const bundle = deployedBundles.find(b => b.name === bundleName); - if (!bundle) return; - - setControlVersionLoadState('loading'); - setTreatmentVersionLoadState('loading'); - - void fetchBundleVersions(bundle.bundleId) - .then(versions => { - const items = versions.map(v => ({ - id: v.versionId, - title: v.versionId.slice(0, 8), - description: `Created: ${formatVersionDate(v.createdAt)}`, - })); - setControlVersionItems(items); - setTreatmentVersionItems(items); - setControlVersionLoadState('loaded'); - setTreatmentVersionLoadState('loaded'); - }) - .catch(() => { - setControlVersionLoadState('error'); - setTreatmentVersionLoadState('error'); - }); - }, - [deployedBundles, fetchBundleVersions] - ); - - // ── Gateway sub-flow state (target-based: "create new" text input) ──────── - const [gatewayCreateMode, setGatewayCreateMode] = useState(false); - - // ── Target picker sub-flow state ────────────────────────────────────────── - // Sub-flow phases: 'pick' -> 'selectRuntime' -> 'selectQualifier' - type TargetSubFlowPhase = 'pick' | 'selectRuntime' | 'selectQualifier'; - const [controlSubFlow, setControlSubFlow] = useState('pick'); - const [controlNewRuntime, setControlNewRuntime] = useState(''); - - const [treatmentSubFlow, setTreatmentSubFlow] = useState('pick'); - const [treatmentNewRuntime, setTreatmentNewRuntime] = useState(''); - - /* eslint-disable react-hooks/set-state-in-effect -- intentional reset on step change */ - useEffect(() => { - if (wizard.step === 'controlTarget') { - setControlSubFlow('pick'); - setControlNewRuntime(''); - } - }, [wizard.step]); - - useEffect(() => { - if (wizard.step === 'treatmentTarget') { - setTreatmentSubFlow('pick'); - setTreatmentNewRuntime(''); - } - }, [wizard.step]); - /* eslint-enable react-hooks/set-state-in-effect */ - - // Step flags - const isModeStep = wizard.step === 'mode'; - const isNameStep = wizard.step === 'name'; - const isDescriptionStep = wizard.step === 'description'; - const isAgentStep = wizard.step === 'agent'; - const isGatewayStep = wizard.step === 'gateway'; - const isVariantsStep = wizard.step === 'variants'; - const isOnlineEvalStep = wizard.step === 'onlineEval'; - const isControlTargetStep = wizard.step === 'controlTarget'; - const isTreatmentTargetStep = wizard.step === 'treatmentTarget'; - const isWeightsStep = wizard.step === 'weights'; - const isEvalPathStep = wizard.step === 'evalPath'; - const isEvalSelectStep = wizard.step === 'evalSelect'; - const isEnableStep = wizard.step === 'enableOnCreate'; - const isConfirmStep = wizard.step === 'confirm'; - - const isTargetBased = wizard.config.mode === 'target-based'; - - // Tell the wizard which steps to skip (both forward and backward navigation). - const gatewayChoiceTypeRef = React.useRef(wizard.config.gatewayChoice.type); - - const shouldSkipStep = useCallback( - (s: string) => { - // Agent selection is only needed in config-bundle mode when auto-creating a gateway. - if (s === 'agent' && (isTargetBased || gatewayChoiceTypeRef.current !== 'create-new')) return true; - // Config-bundle steps skipped in target-based mode - if (s === 'variants' && isTargetBased) return true; - if (s === 'onlineEval' && isTargetBased) return true; - // Target-based steps skipped in config-bundle mode - if (s === 'controlTarget' && !isTargetBased) return true; - if (s === 'treatmentTarget' && !isTargetBased) return true; - if (s === 'weights' && !isTargetBased) return true; - if (s === 'evalPath' && !isTargetBased) return true; - if (s === 'evalSelect' && !isTargetBased) return true; - if (s === 'evalCreate' && !isTargetBased) return true; - if (s === 'evalSamplingRate' && !isTargetBased) return true; - if (s === 'maxDuration') return true; - return false; - }, - [isTargetBased] - ); - - useEffect(() => { - wizard.setSkipCheck(shouldSkipStep); - }, [shouldSkipStep]); // wizard.setSkipCheck is stable (useCallback with no deps) - - // Mode selection items - const modeItems: SelectableItem[] = useMemo( - () => [ - { - id: 'config-bundle', - title: 'Config Bundle', - description: 'Split traffic between config bundle versions (same target, different config)', - }, - { - id: 'target-based', - title: 'Target-Based', - description: 'Split traffic between gateway targets (different targets, each self-contained)', - }, - ], - [] - ); - - // ── Target picker items builder ────────────────────────────────────────── - // Builds the three-section grouped picker items for target selection. - const buildTargetItems = useCallback( - (excludeTarget: TargetInfo | null): SelectableItem[] => { - const items: SelectableItem[] = []; - - // Section 1: Existing targets on the selected gateway - const selectedGw = httpGatewayDetails.find(g => g.name === wizard.config.gateway); - const existingTargets = selectedGw?.targets ?? []; - if (existingTargets.length > 0) { - items.push({ - id: '__section_existing__', - title: '── Existing Targets ──', - description: '', - disabled: true, - }); - for (const t of existingTargets) { - if (excludeTarget?.name === t.name) continue; - items.push({ - id: `existing:${t.name}`, - title: t.name, - description: `endpoint=${t.qualifier} runtime=${t.runtimeRef}`, - }); - } - } - - // Section 2: Endpoints from project runtimes (quick-create targets) - const endpointItems: SelectableItem[] = []; - for (const rt of runtimes) { - for (const ep of rt.endpoints) { - const targetName = ep.name; - if (excludeTarget?.name === targetName) continue; - endpointItems.push({ - id: `endpoint:${rt.name}/${ep.name}`, - title: `${rt.name}/${ep.name}`, - description: `v${ep.version}`, - }); - } - } - if (endpointItems.length > 0) { - items.push({ - id: '__section_endpoints__', - title: '── Endpoints ──', - description: 'Select to auto-create target', - disabled: true, - spaceBefore: items.length > 0, - }); - items.push(...endpointItems); - } - - // Section 3: Create new target - items.push({ - id: '__create_target__', - title: '+ Create new target', - description: 'Configure runtime, name, and endpoint', - spaceBefore: true, - }); - - return items; - }, - [httpGatewayDetails, runtimes, wizard.config.gateway] - ); - - const controlTargetItems = useMemo(() => buildTargetItems(null), [buildTargetItems]); - const treatmentTargetItems = useMemo( - () => buildTargetItems(wizard.config.controlTargetInfo), - [buildTargetItems, wizard.config.controlTargetInfo] - ); - - // Runtime items for the "create new target" sub-flow - const runtimeItems: SelectableItem[] = useMemo( - () => runtimes.map(r => ({ id: r.name, title: r.name, description: `${r.endpoints.length} endpoint(s)` })), - [runtimes] - ); - - // Qualifier items for a given runtime (DEFAULT + all endpoints) - const buildQualifierItems = useCallback( - (runtimeName: string): SelectableItem[] => { - const rt = runtimes.find(r => r.name === runtimeName); - const items: SelectableItem[] = [{ id: 'DEFAULT', title: 'DEFAULT', description: 'Default endpoint' }]; - if (rt) { - for (const ep of rt.endpoints) { - items.push({ id: ep.name, title: ep.name, description: `v${ep.version}` }); - } - } - return items; - }, - [runtimes] - ); - - const controlEndpointItems = useMemo( - () => buildQualifierItems(controlNewRuntime), - [buildQualifierItems, controlNewRuntime] - ); - const treatmentEndpointItems = useMemo( - () => buildQualifierItems(treatmentNewRuntime), - [buildQualifierItems, treatmentNewRuntime] - ); - - // Navigation hooks for select steps - const modeNav = useListNavigation({ - items: modeItems, - onSelect: item => { - if (item.id === 'target-based' && onSwitchToTargetBased) { - onSwitchToTargetBased(); - return; - } - wizard.setMode(item.id as 'config-bundle' | 'target-based'); - }, - onExit: () => wizard.goBack(), - isActive: isModeStep, - }); - - const agentNav = useListNavigation({ - items: agentItems, - onSelect: item => wizard.setAgent(item.id), - onExit: () => wizard.goBack(), - isActive: isAgentStep, - }); - - const gatewayNav = useListNavigation({ - items: gatewayItems, - onSelect: item => { - if (item.id === '__create__') { - setGatewayCreateMode(true); - return; - } - const choice = { type: 'existing-http', name: item.id } as const; - gatewayChoiceTypeRef.current = choice.type; - wizard.setGatewayWithName(item.id, false); - }, - onExit: () => wizard.goBack(), - isActive: isGatewayStep && !gatewayCreateMode, - isDisabled: item => item.disabled === true, - }); - - const onlineEvalNav = useListNavigation({ - items: onlineEvalItems, - onSelect: item => wizard.setOnlineEval(item.id), - onExit: () => wizard.goBack(), - isActive: isOnlineEvalStep, - }); - - // ── Control target picker navigation ───────────────────────────────────── - const controlTargetNav = useListNavigation({ - items: controlTargetItems, - onSelect: item => { - if (item.id === '__create_target__') { - setControlSubFlow('selectRuntime'); - return; - } - if (item.id.startsWith('existing:')) { - const targetName = item.id.replace('existing:', ''); - const selectedGw = httpGatewayDetails.find(g => g.name === wizard.config.gateway); - const target = selectedGw?.targets.find(t => t.name === targetName); - if (target) { - wizard.setControlTarget( - { name: target.name, runtimeRef: target.runtimeRef, qualifier: target.qualifier }, - false - ); - } - return; - } - if (item.id.startsWith('endpoint:')) { - const path = item.id.replace('endpoint:', ''); - const [runtimeName, endpointName] = path.split('/'); - if (runtimeName && endpointName) { - const autoName = `${runtimeName}-${endpointName}`; - wizard.setControlTarget({ name: autoName, runtimeRef: runtimeName, qualifier: endpointName }, true); - } - } - }, - onExit: () => wizard.goBack(), - isActive: isControlTargetStep && controlSubFlow === 'pick', - isDisabled: item => item.disabled === true, - }); - - // Control sub-flow: select runtime - const controlRuntimeNav = useListNavigation({ - items: runtimeItems, - onSelect: item => { - setControlNewRuntime(item.id); - setControlSubFlow('selectQualifier'); - }, - onExit: () => setControlSubFlow('pick'), - isActive: isControlTargetStep && controlSubFlow === 'selectRuntime', - }); - - // Control sub-flow: select qualifier (auto-generates target name) - const controlEndpointNav = useListNavigation({ - items: controlEndpointItems, - onSelect: item => { - const autoName = `${controlNewRuntime}-${item.id}`; - wizard.setControlTarget({ name: autoName, runtimeRef: controlNewRuntime, qualifier: item.id }, true); - }, - onExit: () => setControlSubFlow('selectRuntime'), - isActive: isControlTargetStep && controlSubFlow === 'selectQualifier', - }); - - // ── Treatment target picker navigation ─────────────────────────────────── - const treatmentTargetNav = useListNavigation({ - items: treatmentTargetItems, - onSelect: item => { - if (item.id === '__create_target__') { - setTreatmentSubFlow('selectRuntime'); - return; - } - if (item.id.startsWith('existing:')) { - const targetName = item.id.replace('existing:', ''); - const selectedGw = httpGatewayDetails.find(g => g.name === wizard.config.gateway); - const target = selectedGw?.targets.find(t => t.name === targetName); - if (target) { - wizard.setTreatmentTarget( - { name: target.name, runtimeRef: target.runtimeRef, qualifier: target.qualifier }, - false - ); - } - return; - } - if (item.id.startsWith('endpoint:')) { - const path = item.id.replace('endpoint:', ''); - const [runtimeName, endpointName] = path.split('/'); - if (runtimeName && endpointName) { - const autoName = `${runtimeName}-${endpointName}`; - wizard.setTreatmentTarget({ name: autoName, runtimeRef: runtimeName, qualifier: endpointName }, true); - } - } - }, - onExit: () => wizard.goBack(), - isActive: isTreatmentTargetStep && treatmentSubFlow === 'pick', - isDisabled: item => item.disabled === true, - }); - - // Treatment sub-flow: select runtime - const treatmentRuntimeNav = useListNavigation({ - items: runtimeItems, - onSelect: item => { - setTreatmentNewRuntime(item.id); - setTreatmentSubFlow('selectQualifier'); - }, - onExit: () => setTreatmentSubFlow('pick'), - isActive: isTreatmentTargetStep && treatmentSubFlow === 'selectRuntime', - }); - - // Treatment sub-flow: select qualifier (auto-generates target name) - const treatmentEndpointNav = useListNavigation({ - items: treatmentEndpointItems, - onSelect: item => { - const autoName = `${treatmentNewRuntime}-${item.id}`; - wizard.setTreatmentTarget({ name: autoName, runtimeRef: treatmentNewRuntime, qualifier: item.id }, true); - }, - onExit: () => setTreatmentSubFlow('selectRuntime'), - isActive: isTreatmentTargetStep && treatmentSubFlow === 'selectQualifier', - }); - - const evalPathItems: SelectableItem[] = useMemo( - () => [ - { - id: 'select', - title: 'Select existing online eval configs', - description: 'Use configs already in your project', - }, - { id: 'create', title: 'Create new', description: 'Pick evaluators + sampling rate, auto-create configs' }, - ], - [] - ); - - const evalPathNav = useListNavigation({ - items: evalPathItems, - onSelect: item => wizard.setEvalPath(item.id as 'select' | 'create'), - onExit: () => wizard.goBack(), - isActive: isEvalPathStep, - }); - - // ── Eval select sub-flow: pick control eval, then treatment eval ──────── - type EvalSelectPhase = 'controlEval' | 'treatmentEval'; - const [evalSelectPhase, setEvalSelectPhase] = useState('controlEval'); - const [selectedControlEval, setSelectedControlEval] = useState(''); - - // Reset eval select sub-flow when entering the step - /* eslint-disable react-hooks/set-state-in-effect -- intentional reset on step change */ - useEffect(() => { - if (wizard.step === 'evalSelect') { - setEvalSelectPhase('controlEval'); - setSelectedControlEval(''); - } - }, [wizard.step]); - /* eslint-enable react-hooks/set-state-in-effect */ - - // Filter online eval configs by runtime + endpoint (qualifier) - const controlRuntime = wizard.config.controlTargetInfo?.runtimeRef ?? ''; - const controlEndpoint = wizard.config.controlTargetInfo?.qualifier ?? ''; - const treatmentRuntime = wizard.config.treatmentTargetInfo?.runtimeRef ?? ''; - const treatmentEndpoint = wizard.config.treatmentTargetInfo?.qualifier ?? ''; - - const controlEvalItems: SelectableItem[] = useMemo(() => { - return onlineEvalConfigDetails - .filter(c => c.agent === controlRuntime && (c.endpoint ?? 'DEFAULT') === controlEndpoint) - .map(c => ({ id: c.name, title: c.name, description: `${c.agent}/${c.endpoint ?? 'DEFAULT'}` })); - }, [onlineEvalConfigDetails, controlRuntime, controlEndpoint]); - - const treatmentEvalItems: SelectableItem[] = useMemo(() => { - return onlineEvalConfigDetails - .filter(c => c.agent === treatmentRuntime && (c.endpoint ?? 'DEFAULT') === treatmentEndpoint) - .map(c => ({ id: c.name, title: c.name, description: `${c.agent}/${c.endpoint ?? 'DEFAULT'}` })); - }, [onlineEvalConfigDetails, treatmentRuntime, treatmentEndpoint]); - - const controlEvalNoMatch = isEvalSelectStep && evalSelectPhase === 'controlEval' && controlEvalItems.length === 0; - const treatmentEvalNoMatch = - isEvalSelectStep && evalSelectPhase === 'treatmentEval' && treatmentEvalItems.length === 0; - - const controlEvalNav = useListNavigation({ - items: controlEvalItems, - onSelect: item => { - setSelectedControlEval(item.id); - setEvalSelectPhase('treatmentEval'); - }, - onExit: () => wizard.goBack(), - isActive: isEvalSelectStep && evalSelectPhase === 'controlEval' && !controlEvalNoMatch, - }); - - const treatmentEvalNav = useListNavigation({ - items: treatmentEvalItems, - onSelect: item => { - wizard.setEvalSelect(selectedControlEval, item.id); - }, - onExit: () => setEvalSelectPhase('controlEval'), - isActive: isEvalSelectStep && evalSelectPhase === 'treatmentEval' && !treatmentEvalNoMatch, - }); - - const enableNav = useListNavigation({ - items: enableItems, - onSelect: item => wizard.setEnableOnCreate(item.id === 'yes'), - onExit: () => wizard.goBack(), - isActive: isEnableStep, - }); - - useListNavigation({ - items: [{ id: 'confirm', title: 'Confirm' }], - onSelect: () => onComplete(wizard.config), - onExit: () => wizard.goBack(), - isActive: isConfirmStep, - }); - - // Help text - const isSelectStep = - isModeStep || - isAgentStep || - (isGatewayStep && !gatewayCreateMode) || - isOnlineEvalStep || - isEnableStep || - isControlTargetStep || - isTreatmentTargetStep || - isEvalPathStep || - isEvalSelectStep; - const helpText = isSelectStep - ? HELP_TEXT.NAVIGATE_SELECT - : isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : isVariantsStep - ? HELP_TEXT.VARIANTS_FORM - : HELP_TEXT.TEXT_INPUT; - - const headerContent = ; - - const controlWeight = 100 - wizard.config.treatmentWeight; - - // Format target display for confirm review - const formatTargetDisplay = (info: TargetInfo | null, isNew: boolean): string => { - if (!info) return '(not set)'; - const newLabel = isNew ? ' (new)' : ''; - return `${info.name} endpoint=${info.qualifier} runtime=${info.runtimeRef}${newLabel}`; - }; - - return ( - - - {isModeStep && ( - - )} - - {isNameStep && ( - (existingTestNames.includes(value) ? `AB test "${value}" already exists` : true)} - /> - )} - - {isDescriptionStep && ( - wizard.goBack()} - /> - )} - - {isAgentStep && } - - {/* ── Step 4: Gateway selection ──────────────────────────── */} - {isGatewayStep && !gatewayCreateMode && ( - - )} - {isGatewayStep && gatewayCreateMode && ( - { - gatewayChoiceTypeRef.current = 'create-new'; - wizard.setGatewayWithName(name, true); - setGatewayCreateMode(false); - }} - onCancel={() => setGatewayCreateMode(false)} - /> - )} - - {isVariantsStep && ( - wizard.goBack()} - onCreateBundle={onCreateBundle} - /> - )} - - {/* ── Step 5: Control target selection ─────────────────── */} - {isControlTargetStep && controlSubFlow === 'pick' && ( - - )} - {isControlTargetStep && controlSubFlow === 'selectRuntime' && ( - - )} - {isControlTargetStep && controlSubFlow === 'selectQualifier' && ( - - )} - - {/* ── Step 6: Treatment target selection ───────────────── */} - {isTreatmentTargetStep && treatmentSubFlow === 'pick' && ( - - {wizard.config.controlTargetInfo && ( - - - {'\u2713'} Control: {wizard.config.controlTargetInfo.name} endpoint= - {wizard.config.controlTargetInfo.qualifier} - - - )} - - - )} - {isTreatmentTargetStep && treatmentSubFlow === 'selectRuntime' && ( - - )} - {isTreatmentTargetStep && treatmentSubFlow === 'selectQualifier' && ( - - )} - - {/* ── Target-based: Traffic weights ───────────────────── */} - {isWeightsStep && ( - { - const w = parseInt(value, 10); - if (!isNaN(w) && w >= 1 && w <= 99) { - wizard.setWeights(w, 100 - w); - } - }} - onCancel={() => wizard.goBack()} - customValidation={value => { - const w = parseInt(value, 10); - if (isNaN(w)) return 'Must be a number'; - if (w < 1 || w > 99) return 'Must be between 1 and 99'; - return true; - }} - /> - )} - - {/* ── Target-based: Eval path selection ───────────────── */} - {isEvalPathStep && ( - - )} - - {/* ── Target-based: Eval select (control) ───────────── */} - {isEvalSelectStep && evalSelectPhase === 'controlEval' && !controlEvalNoMatch && ( - - )} - {isEvalSelectStep && evalSelectPhase === 'controlEval' && controlEvalNoMatch && ( - - No online eval config found for {controlRuntime}/{controlEndpoint}. Create one first: agentcore add - online-eval --runtime {controlRuntime} --endpoint {controlEndpoint} - - )} - - {/* ── Target-based: Eval select (treatment) ─────────── */} - {isEvalSelectStep && evalSelectPhase === 'treatmentEval' && !treatmentEvalNoMatch && ( - - - - {'\u2713'} Control eval: {selectedControlEval} - - - - - )} - {isEvalSelectStep && evalSelectPhase === 'treatmentEval' && treatmentEvalNoMatch && ( - - No online eval config found for {treatmentRuntime}/{treatmentEndpoint}. Create one first: agentcore add - online-eval --runtime {treatmentRuntime} --endpoint {treatmentEndpoint} - - )} - - {/* ── Config-bundle: Online eval selection ────────────── */} - {isOnlineEvalStep && - (onlineEvalItems.length > 0 ? ( - - ) : ( - - No online eval configs found. An online eval is required for AB tests. Add one with `agentcore add - online-eval`, then retry. Press Esc to go back. - - ))} - - {/* TODO(post-preview): Re-enable maxDuration TextInput once configurable duration is launched. */} - - {isEnableStep && ( - - )} - - {isConfirmStep && ( - - )} - - - ); -} diff --git a/src/cli/tui/screens/ab-test/RemoveABTestScreen.tsx b/src/cli/tui/screens/ab-test/RemoveABTestScreen.tsx deleted file mode 100644 index 48adc621f..000000000 --- a/src/cli/tui/screens/ab-test/RemoveABTestScreen.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { RemovableResource } from '../../../primitives/types'; -import type { SelectableItem } from '../../components'; -import { SelectScreen } from '../../components'; -import React, { useMemo } from 'react'; - -interface RemoveABTestScreenProps { - abTests: RemovableResource[]; - onSelect: (testName: string) => void; - onExit: () => void; -} - -export function RemoveABTestScreen({ abTests, onSelect, onExit }: RemoveABTestScreenProps) { - const items: SelectableItem[] = useMemo( - () => - abTests.map(t => ({ - id: t.name, - title: t.name, - description: 'AB Test', - })), - [abTests] - ); - - return ( - onSelect(item.id)} onExit={onExit} /> - ); -} diff --git a/src/cli/tui/screens/ab-test/TargetBasedABTestScreen.tsx b/src/cli/tui/screens/ab-test/TargetBasedABTestScreen.tsx deleted file mode 100644 index 60b92dd45..000000000 --- a/src/cli/tui/screens/ab-test/TargetBasedABTestScreen.tsx +++ /dev/null @@ -1,712 +0,0 @@ -import type { SelectableItem } from '../../components'; -import { - ConfirmReview, - Cursor, - Panel, - Screen, - StepIndicator, - TextInput, - TwoColumn, - WizardSelect, -} from '../../components'; -import { HELP_TEXT } from '../../constants'; -import { useListNavigation } from '../../hooks'; -import { usePanelNavigation } from '../../hooks/usePanelNavigation'; -import type { HttpGatewayInfo, OnlineEvalConfigInfo, RuntimeInfo } from './AddABTestScreen'; -import type { AddABTestConfig, TargetInfo } from './types'; -import { TARGET_BASED_STEP_LABELS, useTargetBasedWizard } from './useTargetBasedWizard'; -import { Box, Text, useInput } from 'ink'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; - -// ───────────────────────────────────────────────────────────────────────────── -// Props -// ───────────────────────────────────────────────────────────────────────────── - -interface TargetBasedABTestScreenProps { - onComplete: (config: AddABTestConfig) => void; - onExit: () => void; - existingTestNames: string[]; - runtimes: RuntimeInfo[]; - httpGatewayDetails: HttpGatewayInfo[]; - existingHttpGateways: string[]; - onlineEvalConfigDetails: OnlineEvalConfigInfo[]; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Builder field indices -// ───────────────────────────────────────────────────────────────────────────── - -const FIELD_TARGET = 0; -const FIELD_WEIGHT = 1; -const FIELD_EVAL = 2; -const FIELD_COUNT = 3; - -// ───────────────────────────────────────────────────────────────────────────── -// VariantColumn sub-component -// ───────────────────────────────────────────────────────────────────────────── - -interface VariantColumnProps { - label: string; - color: string; - isActive: boolean; - focusedField: number | null; - activeField: number | null; - targetInfo: TargetInfo | null; - weight: number; - evalConfigName: string; - targetItems: SelectableItem[]; - targetNavIndex: number; - evalItems: SelectableItem[]; - evalNavIndex: number; - onWeightSubmit: (value: string) => void; - onWeightCancel: () => void; -} - -function VariantColumn({ - label, - color, - isActive, - focusedField, - activeField, - targetInfo, - weight, - evalConfigName, - targetItems, - targetNavIndex, - evalItems, - evalNavIndex, - onWeightSubmit, - onWeightCancel, -}: VariantColumnProps) { - const borderColor = isActive ? color : 'gray'; - - const fieldLabel = (idx: number, text: string, value: string) => { - const isFocused = focusedField === idx; - const isFieldActive = activeField === idx; - const prefix = isFocused || isFieldActive ? '>' : ' '; - const checkmark = value && value !== '(not set)' ? '\u2713 ' : ''; - - return ( - - - {prefix} {text}:{' '} - - - {checkmark} - {value} - - - ); - }; - - return ( - - - {label} - - - {/* Target field */} - {activeField === FIELD_TARGET ? ( - - ) : ( - fieldLabel( - FIELD_TARGET, - 'Target', - targetInfo ? `${targetInfo.name} (${targetInfo.runtimeRef}/${targetInfo.qualifier})` : '(not set)' - ) - )} - - {/* Weight field */} - {activeField === FIELD_WEIGHT ? ( - { - const w = parseInt(value, 10); - if (isNaN(w)) return 'Must be a number'; - if (w < 1 || w > 99) return 'Must be between 1 and 99'; - return true; - }} - /> - ) : ( - fieldLabel(FIELD_WEIGHT, 'Weight', `${weight}%`) - )} - - {/* Eval config field */} - {activeField === FIELD_EVAL ? ( - evalItems.length > 0 ? ( - - ) : ( - - No eval config found for this target. - Press Esc to go back. Create one with: agentcore add online-eval - - ) - ) : ( - fieldLabel(FIELD_EVAL, 'Eval', evalConfigName || '(optional)') - )} - - - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Main Screen -// ───────────────────────────────────────────────────────────────────────────── - -export function TargetBasedABTestScreen({ - onComplete, - onExit, - existingTestNames, - runtimes, - httpGatewayDetails, - existingHttpGateways, - onlineEvalConfigDetails, -}: TargetBasedABTestScreenProps) { - const wizard = useTargetBasedWizard(); - - // ── Name/Description multi-field form ─────────────────────────────────── - type NameField = 'name' | 'description'; - const NAME_FIELDS: NameField[] = ['name', 'description']; - const [activeNameField, setActiveNameField] = useState('name'); - const [nameValue, setNameValue] = useState(''); - const [descriptionValue, setDescriptionValue] = useState(''); - const [nameError, setNameError] = useState(null); - const [gatewayCreateMode, setGatewayCreateMode] = useState(false); - - // Step flags - const isNameStep = wizard.step === 'nameDescription'; - const isGatewayStep = wizard.step === 'gateway'; - const isBuilderStep = wizard.step === 'builder'; - const isEnableStep = wizard.step === 'enableOnCreate'; - const isConfirmStep = wizard.step === 'confirm'; - - // ── Name/Description input handler ───────────────────────────────────── - useInput( - (input, key) => { - if (!isNameStep) return; - - if (key.escape) { - if (activeNameField === 'description') { - setActiveNameField('name'); - } else { - onExit(); - } - return; - } - - if (key.tab || key.upArrow || key.downArrow) { - const idx = NAME_FIELDS.indexOf(activeNameField); - if (key.shift || key.upArrow) { - setActiveNameField(NAME_FIELDS[(idx - 1 + NAME_FIELDS.length) % NAME_FIELDS.length]!); - } else { - setActiveNameField(NAME_FIELDS[(idx + 1) % NAME_FIELDS.length]!); - } - setNameError(null); - return; - } - - if (key.return) { - if (activeNameField === 'name') { - if (!nameValue.trim()) { - setNameError('Name is required'); - return; - } - if (!/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/.test(nameValue.trim())) { - setNameError('Must begin with a letter, alphanumeric + underscores only (max 48 chars)'); - return; - } - if (existingTestNames.includes(nameValue.trim())) { - setNameError(`AB test "${nameValue.trim()}" already exists`); - return; - } - setActiveNameField('description'); - setNameError(null); - return; - } - // On description, submit both - if (!nameValue.trim()) { - setNameError('Name is required'); - setActiveNameField('name'); - return; - } - wizard.setName(nameValue.trim()); - wizard.setDescription(descriptionValue.trim()); - wizard.advanceFromNameDescription(); - return; - } - - // Text input - if (key.backspace || key.delete) { - if (activeNameField === 'name') setNameValue(v => v.slice(0, -1)); - else setDescriptionValue(v => v.slice(0, -1)); - setNameError(null); - return; - } - if (input && !key.ctrl && !key.meta) { - if (activeNameField === 'name') setNameValue(v => v + input); - else setDescriptionValue(v => v + input); - setNameError(null); - } - }, - { isActive: isNameStep } - ); - - // ── Gateway items ─────────────────────────────────────────────────────── - const gatewayItems: SelectableItem[] = useMemo(() => { - const items: SelectableItem[] = []; - for (const gwName of existingHttpGateways) { - items.push({ id: gwName, title: gwName, description: 'Existing HTTP gateway' }); - } - items.push({ - id: '__create__', - title: 'Create new gateway', - description: 'Auto-create for this AB test', - }); - return items; - }, [existingHttpGateways]); - - // ── Target items builder ──────────────────────────────────────────────── - const buildTargetItems = useCallback( - (excludeTarget: TargetInfo | null): SelectableItem[] => { - const items: SelectableItem[] = []; - - // Section 1: Existing targets on the selected gateway - const selectedGw = httpGatewayDetails.find(g => g.name === wizard.config.gateway); - const existingTargets = selectedGw?.targets ?? []; - if (existingTargets.length > 0) { - items.push({ - id: '__section_existing__', - title: '── Gateway Targets ──', - description: '', - disabled: true, - }); - for (const t of existingTargets) { - if (t.name === excludeTarget?.name) continue; - items.push({ - id: `existing:${t.name}`, - title: t.name, - description: `${t.runtimeRef}/${t.qualifier}`, - }); - } - } - - // Section 2: Runtime endpoints (auto-create targets) - const endpointItems: SelectableItem[] = []; - for (const rt of runtimes) { - for (const ep of rt.endpoints) { - const targetName = `${rt.name}-${ep.name}`; - if (targetName === excludeTarget?.name) continue; - endpointItems.push({ - id: `endpoint:${rt.name}/${ep.name}`, - title: `${rt.name}/${ep.name}`, - description: `v${ep.version}`, - }); - } - } - if (endpointItems.length > 0) { - items.push({ - id: '__section_endpoints__', - title: '── Runtime Endpoints ──\n Select to auto-create target', - description: '', - disabled: true, - spaceBefore: items.length > 0, - }); - items.push(...endpointItems); - } - - return items; - }, - [httpGatewayDetails, runtimes, wizard.config.gateway] - ); - - const controlTargetItems = useMemo(() => buildTargetItems(null), [buildTargetItems]); - const treatmentTargetItems = useMemo( - () => buildTargetItems(wizard.config.controlTargetInfo), - [buildTargetItems, wizard.config.controlTargetInfo] - ); - - // ── Eval items (auto-matched by runtime + endpoint) ───────────────────── - const buildEvalItems = useCallback( - (targetInfo: TargetInfo | null): SelectableItem[] => { - if (!targetInfo) return []; - return onlineEvalConfigDetails - .filter(c => c.agent === targetInfo.runtimeRef && (c.endpoint ?? 'DEFAULT') === targetInfo.qualifier) - .map(c => ({ id: c.name, title: c.name, description: `${c.agent}/${c.endpoint ?? 'DEFAULT'}` })); - }, - [onlineEvalConfigDetails] - ); - - const controlEvalItems = useMemo( - () => buildEvalItems(wizard.config.controlTargetInfo), - [buildEvalItems, wizard.config.controlTargetInfo] - ); - const treatmentEvalItems = useMemo( - () => buildEvalItems(wizard.config.treatmentTargetInfo), - [buildEvalItems, wizard.config.treatmentTargetInfo] - ); - - // Auto-match eval when target is selected and exactly one match exists - useEffect(() => { - if (wizard.config.controlTargetInfo && controlEvalItems.length === 1 && !wizard.config.controlOnlineEval) { - wizard.setControlEval(controlEvalItems[0]!.id); - } - }, [wizard.config.controlTargetInfo, controlEvalItems, wizard.config.controlOnlineEval, wizard.setControlEval]); - - useEffect(() => { - if (wizard.config.treatmentTargetInfo && treatmentEvalItems.length === 1 && !wizard.config.treatmentOnlineEval) { - wizard.setTreatmentEval(treatmentEvalItems[0]!.id); - } - }, [ - wizard.config.treatmentTargetInfo, - treatmentEvalItems, - wizard.config.treatmentOnlineEval, - wizard.setTreatmentEval, - ]); - - // ── Enable items ──────────────────────────────────────────────────────── - const enableItems: SelectableItem[] = useMemo( - () => [ - { id: 'yes', title: 'Yes', description: 'Start the AB test immediately after deploy' }, - { id: 'no', title: 'No', description: 'Create paused — start manually later' }, - ], - [] - ); - - // ── Panel navigation for the builder step ─────────────────────────────── - const panel = usePanelNavigation({ - isActive: isBuilderStep, - fieldCount: FIELD_COUNT, - onExit: () => wizard.goBack(), - onComplete: () => wizard.advance(), - }); - - // ── Target selection handler ──────────────────────────────────────────── - const handleTargetSelect = useCallback( - (column: number, item: SelectableItem) => { - const setter = column === 0 ? wizard.setControlTarget : wizard.setTreatmentTarget; - - if (item.id.startsWith('existing:')) { - const targetName = item.id.replace('existing:', ''); - const selectedGw = httpGatewayDetails.find(g => g.name === wizard.config.gateway); - const target = selectedGw?.targets.find(t => t.name === targetName); - if (target) { - setter({ name: target.name, runtimeRef: target.runtimeRef, qualifier: target.qualifier }, false); - } - } else if (item.id.startsWith('endpoint:')) { - const path = item.id.replace('endpoint:', ''); - const [runtimeName, endpointName] = path.split('/'); - if (runtimeName && endpointName) { - const autoName = `${runtimeName}-${endpointName}`; - setter({ name: autoName, runtimeRef: runtimeName, qualifier: endpointName }, true); - } - } - panel.deactivate(); - }, - [httpGatewayDetails, wizard.config.gateway, wizard.setControlTarget, wizard.setTreatmentTarget, panel] - ); - - // ── List navigations for builder pickers ──────────────────────────────── - - // Control target picker - const controlTargetNav = useListNavigation({ - items: controlTargetItems, - onSelect: item => handleTargetSelect(0, item), - onExit: () => panel.deactivate(), - isActive: panel.isFieldActive(0, FIELD_TARGET), - isDisabled: item => item.disabled === true, - }); - - // Treatment target picker - const treatmentTargetNav = useListNavigation({ - items: treatmentTargetItems, - onSelect: item => handleTargetSelect(1, item), - onExit: () => panel.deactivate(), - isActive: panel.isFieldActive(1, FIELD_TARGET), - isDisabled: item => item.disabled === true, - }); - - // Control eval picker - const controlEvalNav = useListNavigation({ - items: controlEvalItems, - onSelect: item => { - wizard.setControlEval(item.id); - panel.deactivate(); - }, - onExit: () => panel.deactivate(), - isActive: panel.isFieldActive(0, FIELD_EVAL), - }); - - // Treatment eval picker - const treatmentEvalNav = useListNavigation({ - items: treatmentEvalItems, - onSelect: item => { - wizard.setTreatmentEval(item.id); - panel.deactivate(); - }, - onExit: () => panel.deactivate(), - isActive: panel.isFieldActive(1, FIELD_EVAL), - }); - - // ── Non-builder navigation hooks ──────────────────────────────────────── - - const gatewayNav = useListNavigation({ - items: gatewayItems, - onSelect: item => { - if (item.id === '__create__') { - setGatewayCreateMode(true); - return; - } - wizard.setGateway(item.id, false); - }, - onExit: () => wizard.goBack(), - isActive: isGatewayStep && !gatewayCreateMode, - isDisabled: item => item.disabled === true, - }); - - const enableNav = useListNavigation({ - items: enableItems, - onSelect: item => wizard.setEnableOnCreate(item.id === 'yes'), - onExit: () => wizard.goBack(), - isActive: isEnableStep, - }); - - // Builder "Continue" navigation — when all fields filled, Enter on confirm row advances - const builderContinueItems: SelectableItem[] = useMemo( - () => (wizard.isBuilderComplete ? [{ id: 'continue', title: 'Continue' }] : []), - [wizard.isBuilderComplete] - ); - - const _builderContinueNav = useListNavigation({ - items: builderContinueItems, - onSelect: () => wizard.advance(), - onExit: () => wizard.goBack(), - isActive: false, // Controlled programmatically below - }); - - useListNavigation({ - items: [{ id: 'confirm', title: 'Confirm' }], - onSelect: () => onComplete(wizard.toAddABTestConfig()), - onExit: () => wizard.goBack(), - isActive: isConfirmStep, - }); - - // ── Help text ─────────────────────────────────────────────────────────── - const isSelectStep = (isGatewayStep && !gatewayCreateMode) || isEnableStep; - const helpText = isSelectStep - ? HELP_TEXT.NAVIGATE_SELECT - : isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : isBuilderStep - ? 'Tab switch column \u00B7 \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc back' - : HELP_TEXT.TEXT_INPUT; - - const headerContent = ( - - ); - - // ── Format display helpers ────────────────────────────────────────────── - const formatTargetDisplay = (info: TargetInfo | null, isNew: boolean): string => { - if (!info) return '(not set)'; - const newLabel = isNew ? ' (new)' : ''; - return `${info.name} endpoint=${info.qualifier} runtime=${info.runtimeRef}${newLabel}`; - }; - - // ── Weight submit handlers ────────────────────────────────────────────── - const handleControlWeightSubmit = useCallback( - (value: string) => { - const w = parseInt(value, 10); - if (!isNaN(w) && w >= 1 && w <= 99) { - wizard.setControlWeight(w); - } - panel.deactivate(); - }, - [wizard, panel] - ); - - const handleTreatmentWeightSubmit = useCallback( - (value: string) => { - const w = parseInt(value, 10); - if (!isNaN(w) && w >= 1 && w <= 99) { - // Treatment weight setter: set control to 100 - treatment - wizard.setControlWeight(100 - w); - } - panel.deactivate(); - }, - [wizard, panel] - ); - - const handleWeightCancel = useCallback(() => { - panel.deactivate(); - }, [panel]); - - return ( - - - {/* ── Step 1: Name + Description ─────────────────────── */} - {isNameStep && ( - - - {'Name: '} - {activeNameField === 'name' && !nameValue && } - - {nameValue || {'e.g., my-ab-test'}} - - {activeNameField === 'name' && nameValue ? : null} - - - {'Description: '} - {activeNameField === 'description' && !descriptionValue && } - - {descriptionValue || {'(optional)'}} - - {activeNameField === 'description' && descriptionValue ? : null} - - {nameError && ( - - {nameError} - - )} - - )} - - {/* ── Step 2: Gateway ────────────────────────────────── */} - {isGatewayStep && !gatewayCreateMode && ( - - )} - {isGatewayStep && gatewayCreateMode && ( - { - wizard.setGateway(name, true); - setGatewayCreateMode(false); - }} - onCancel={() => setGatewayCreateMode(false)} - /> - )} - - {/* ── Step 3: Side-by-Side Builder ───────────────────── */} - {isBuilderStep && ( - - - } - right={ - - } - /> - {wizard.isBuilderComplete && ( - - - {'\u2713'} All fields configured. Press Enter to continue, or adjust values above. - - - )} - {!wizard.isBuilderComplete && ( - - Configure both columns, then press Enter to continue. - - )} - - )} - - {/* ── Step 4: Enable on Create ───────────────────────── */} - {isEnableStep && ( - - )} - - {/* ── Step 5: Confirm ────────────────────────────────── */} - {isConfirmStep && ( - - )} - - - ); -} diff --git a/src/cli/tui/screens/ab-test/VariantConfigForm.tsx b/src/cli/tui/screens/ab-test/VariantConfigForm.tsx deleted file mode 100644 index 61f465323..000000000 --- a/src/cli/tui/screens/ab-test/VariantConfigForm.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import type { SelectableItem } from '../../components'; -import { TextInput, WizardSelect } from '../../components'; -import { useListNavigation } from '../../hooks'; -import { Box, Text } from 'ink'; -import React, { useCallback, useMemo, useState } from 'react'; - -type VariantSubField = 'controlBundle' | 'controlVersion' | 'treatmentBundle' | 'treatmentVersion' | 'treatmentWeight'; - -const SUB_FIELDS: VariantSubField[] = [ - 'controlBundle', - 'controlVersion', - 'treatmentBundle', - 'treatmentVersion', - 'treatmentWeight', -]; - -export interface VariantConfig { - controlBundle: string; - controlVersion: string; - treatmentBundle: string; - treatmentVersion: string; - treatmentWeight: number; -} - -export type VersionLoadState = 'idle' | 'loading' | 'loaded' | 'error'; - -interface VariantConfigFormProps { - bundleItems: SelectableItem[]; - fetchVersionItems: (bundleName: string) => void; - controlVersionItems: SelectableItem[]; - treatmentVersionItems: SelectableItem[]; - controlVersionLoadState: VersionLoadState; - treatmentVersionLoadState: VersionLoadState; - onComplete: (config: VariantConfig) => void; - onCancel: () => void; - onCreateBundle?: () => void; -} - -export function VariantConfigForm({ - bundleItems, - fetchVersionItems, - controlVersionItems, - treatmentVersionItems, - controlVersionLoadState, - treatmentVersionLoadState, - onComplete, - onCancel, - onCreateBundle, -}: VariantConfigFormProps) { - const [activeField, setActiveField] = useState('controlBundle'); - const [controlBundle, setControlBundle] = useState(''); - const [controlVersion, setControlVersion] = useState(''); - const [treatmentBundle, setTreatmentBundle] = useState(''); - const [treatmentVersion, setTreatmentVersion] = useState(''); - const [treatmentWeight, setTreatmentWeight] = useState('20'); - - const augmentedBundleItems: SelectableItem[] = useMemo(() => { - const items: SelectableItem[] = []; - if (onCreateBundle) { - items.push({ id: '__create_bundle__', title: 'Create new config bundle', description: 'Add a new bundle first' }); - } - items.push(...bundleItems); - return items; - }, [bundleItems, onCreateBundle]); - - const advanceField = useCallback(() => { - const idx = SUB_FIELDS.indexOf(activeField); - const next = SUB_FIELDS[idx + 1]; - if (next) setActiveField(next); - }, [activeField]); - - // Navigation for each select sub-field - const controlBundleNav = useListNavigation({ - items: augmentedBundleItems, - onSelect: item => { - if (item.id === '__create_bundle__') { - onCreateBundle?.(); - return; - } - setControlBundle(item.id); - fetchVersionItems(item.id); - advanceField(); - }, - onExit: onCancel, - isActive: activeField === 'controlBundle', - }); - - const controlVersionNav = useListNavigation({ - items: controlVersionItems, - onSelect: item => { - setControlVersion(item.id); - advanceField(); - }, - onExit: () => setActiveField('controlBundle'), - isActive: activeField === 'controlVersion' && controlVersionLoadState === 'loaded', - }); - - const treatmentBundleNav = useListNavigation({ - items: augmentedBundleItems, - onSelect: item => { - if (item.id === '__create_bundle__') { - onCreateBundle?.(); - return; - } - setTreatmentBundle(item.id); - fetchVersionItems(item.id); - advanceField(); - }, - onExit: () => setActiveField('controlVersion'), - isActive: activeField === 'treatmentBundle', - }); - - const treatmentVersionNav = useListNavigation({ - items: treatmentVersionItems, - onSelect: item => { - setTreatmentVersion(item.id); - advanceField(); - }, - onExit: () => setActiveField('treatmentBundle'), - isActive: activeField === 'treatmentVersion' && treatmentVersionLoadState === 'loaded', - }); - - const controlWeight = 100 - parseInt(treatmentWeight || '0', 10); - - const completedValue = (value: string, label: string) => ( - - {label}: - {value || '(pending)'} - {value && } - - ); - - const pendingValue = (label: string) => ( - - {label}: - (pending) - - ); - - const renderVersionField = ( - isActive: boolean, - loadState: VersionLoadState, - items: SelectableItem[], - nav: { selectedIndex: number }, - title: string, - completedVersion: string, - label: string - ) => { - if (!isActive) { - return completedVersion ? completedValue(completedVersion.slice(0, 8), label) : pendingValue(label); - } - - switch (loadState) { - case 'loading': - return {label}: Loading versions...; - case 'error': - return {label}: Failed to load versions. Press Esc to go back and retry.; - case 'loaded': - if (items.length === 0) { - return {label}: No versions found. Deploy the config bundle first.; - } - return ; - default: - return {label}: Waiting...; - } - }; - - return ( - - Configure Variants - - {/* Control section */} - - - Control (C): - - - {activeField === 'controlBundle' ? ( - augmentedBundleItems.length > 0 ? ( - - ) : ( - No deployed config bundles found. - ) - ) : ( - completedValue(controlBundle, ' Bundle') - )} - - {renderVersionField( - activeField === 'controlVersion', - controlVersionLoadState, - controlVersionItems, - controlVersionNav, - ' Select control version', - controlVersion, - ' Version' - )} - - - {/* Treatment section */} - - - Treatment (T1): - - - {activeField === 'treatmentBundle' ? ( - - ) : treatmentBundle ? ( - completedValue(treatmentBundle, ' Bundle') - ) : ( - pendingValue(' Bundle') - )} - - {renderVersionField( - activeField === 'treatmentVersion', - treatmentVersionLoadState, - treatmentVersionItems, - treatmentVersionNav, - ' Select treatment version', - treatmentVersion, - ' Version' - )} - - {activeField === 'treatmentWeight' ? ( - - setTreatmentWeight(value)} - onSubmit={value => { - const n = parseInt(value, 10); - if (!isNaN(n) && n >= 1 && n <= 99) { - setTreatmentWeight(value); - onComplete({ - controlBundle, - controlVersion, - treatmentBundle, - treatmentVersion, - treatmentWeight: n, - }); - } - }} - onCancel={() => setActiveField('treatmentVersion')} - customValidation={(value: string) => { - const n = parseInt(value, 10); - if (isNaN(n)) return 'Must be a number'; - if (n < 1 || n > 99) return 'Must be between 1 and 99'; - return true; - }} - /> - - ) : treatmentWeight && treatmentVersion ? ( - completedValue(`${treatmentWeight}% (control: ${controlWeight}%)`, ' Weight') - ) : ( - pendingValue(' Weight') - )} - - - ); -} diff --git a/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx b/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx deleted file mode 100644 index 082d7662b..000000000 --- a/src/cli/tui/screens/ab-test/__tests__/useAddABTestWizard.test.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import type { VariantConfig } from '../VariantConfigForm'; -import type { GatewayChoice } from '../types'; -import type { StepSkipCheck } from '../useAddABTestWizard'; -import { useAddABTestWizard } from '../useAddABTestWizard'; -import { Text } from 'ink'; -import { render } from 'ink-testing-library'; -import React, { act, useImperativeHandle } from 'react'; -import { describe, expect, it } from 'vitest'; - -// ── Simple harness ───────────────────────────────────────────────────────── - -function Harness() { - const wizard = useAddABTestWizard(); - return ( - - step:{wizard.step} - name:{wizard.config.name} - treatmentWeight:{wizard.config.treatmentWeight} - enableOnCreate:{String(wizard.config.enableOnCreate)} - steps:{wizard.steps.join(',')} - - ); -} - -// ── Imperative harness ───────────────────────────────────────────────────── - -interface HarnessHandle { - setName: (name: string) => void; - setDescription: (desc: string) => void; - setAgent: (agent: string) => void; - setGateway: (choice: GatewayChoice) => void; - setVariants: (vc: VariantConfig) => void; - setOnlineEval: (eval_: string) => void; - setMaxDuration: (days: number | undefined) => void; - setEnableOnCreate: (enable: boolean) => void; - setSkipCheck: (check: StepSkipCheck) => void; - goBack: () => void; - reset: () => void; -} - -const ImperativeHarness = React.forwardRef((_, ref) => { - const wizard = useAddABTestWizard(); - useImperativeHandle(ref, () => ({ - setName: wizard.setName, - setDescription: wizard.setDescription, - setAgent: wizard.setAgent, - setGateway: wizard.setGateway, - setVariants: wizard.setVariants, - setOnlineEval: wizard.setOnlineEval, - setMaxDuration: wizard.setMaxDuration, - setEnableOnCreate: wizard.setEnableOnCreate, - setSkipCheck: wizard.setSkipCheck, - goBack: wizard.goBack, - reset: wizard.reset, - })); - return ( - - step:{wizard.step} - name:{wizard.config.name} - description:{wizard.config.description} - agent:{wizard.config.agent} - controlBundle:{wizard.config.controlBundle} - treatmentWeight:{wizard.config.treatmentWeight} - onlineEval:{wizard.config.onlineEval} - maxDuration:{String(wizard.config.maxDuration ?? 'undefined')} - enableOnCreate:{String(wizard.config.enableOnCreate)} - - ); -}); -ImperativeHarness.displayName = 'ImperativeHarness'; - -// ── Tests ────────────────────────────────────────────────────────────────── - -describe('useAddABTestWizard', () => { - describe('defaults', () => { - it('default step is mode', () => { - const { lastFrame } = render(); - expect(lastFrame()).toContain('step:mode'); - }); - - it('default treatment weight is 20', () => { - const { lastFrame } = render(); - expect(lastFrame()).toContain('treatmentWeight:20'); - }); - - it('default enableOnCreate is true', () => { - const { lastFrame } = render(); - expect(lastFrame()).toContain('enableOnCreate:true'); - }); - - it('has all 10 steps', () => { - const { lastFrame } = render(); - const frame = lastFrame()!.replace(/\n/g, ''); - expect(frame).toContain( - 'steps:mode,name,description,gateway,agent,variants,onlineEval,maxDuration,enableOnCreate,confirm' - ); - }); - }); - - describe('step navigation', () => { - it('setName advances to description', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setName('Test1')); - - expect(lastFrame()).toContain('step:description'); - expect(lastFrame()).toContain('name:Test1'); - }); - - it('setDescription advances to gateway', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setName('Test1')); - act(() => ref.current!.setDescription('desc')); - - expect(lastFrame()).toContain('step:gateway'); - expect(lastFrame()).toContain('description:desc'); - }); - - it('setGateway advances to agent', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setName('T')); - act(() => ref.current!.setDescription('')); - act(() => ref.current!.setGateway({ type: 'create-new' })); - - expect(lastFrame()).toContain('step:agent'); - }); - - it('setAgent advances to variants', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setName('T')); - act(() => ref.current!.setDescription('')); - act(() => ref.current!.setGateway({ type: 'create-new' })); - act(() => ref.current!.setAgent('my-agent')); - - expect(lastFrame()).toContain('step:variants'); - expect(lastFrame()).toContain('agent:my-agent'); - }); - - it('setVariants advances to onlineEval', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setName('T')); - act(() => ref.current!.setDescription('')); - act(() => ref.current!.setGateway({ type: 'create-new' })); - act(() => ref.current!.setAgent('my-agent')); - act(() => - ref.current!.setVariants({ - controlBundle: 'cb', - controlVersion: 'v1', - treatmentBundle: 'tb', - treatmentVersion: 'v2', - treatmentWeight: 30, - }) - ); - - expect(lastFrame()).toContain('step:onlineEval'); - expect(lastFrame()).toContain('controlBundle:cb'); - expect(lastFrame()).toContain('treatmentWeight:30'); - }); - - it('full wizard reaches confirm step', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setName('T')); - act(() => ref.current!.setDescription('')); - act(() => ref.current!.setGateway({ type: 'create-new' })); - act(() => ref.current!.setAgent('my-agent')); - act(() => - ref.current!.setVariants({ - controlBundle: 'cb', - controlVersion: 'v1', - treatmentBundle: 'tb', - treatmentVersion: 'v2', - treatmentWeight: 25, - }) - ); - act(() => ref.current!.setOnlineEval('eval-arn')); - act(() => ref.current!.setMaxDuration(30)); - act(() => ref.current!.setEnableOnCreate(false)); - - const frame = lastFrame()!.replace(/\n/g, ''); - expect(frame).toContain('step:confirm'); - expect(frame).toContain('enableOnCreate:false'); - expect(frame).toContain('maxDuration:30'); - }); - }); - - describe('goBack', () => { - it('goes back from description to name', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setName('T')); - expect(lastFrame()).toContain('step:description'); - - act(() => ref.current!.goBack()); - expect(lastFrame()).toContain('step:name'); - }); - - it('does not go back from first step', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.goBack()); - expect(lastFrame()).toContain('step:mode'); - }); - }); - - describe('reset', () => { - it('resets to initial state', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setName('Test1')); - act(() => ref.current!.setDescription('desc')); - expect(lastFrame()).toContain('step:gateway'); - - act(() => ref.current!.reset()); - - expect(lastFrame()).toContain('step:mode'); - expect(lastFrame()).toContain('name:'); - expect(lastFrame()).toContain('treatmentWeight:20'); - }); - }); - - describe('skip check', () => { - it('advance skips over steps marked as skippable', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setSkipCheck(s => s === 'gateway')); - act(() => ref.current!.setName('T')); - act(() => ref.current!.setDescription('')); - act(() => ref.current!.setAgent('my-agent')); - - expect(lastFrame()).toContain('step:variants'); - }); - - it('goBack skips over steps marked as skippable', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setName('T')); - act(() => ref.current!.setDescription('')); - act(() => ref.current!.setGateway({ type: 'create-new' })); - act(() => ref.current!.setAgent('my-agent')); - expect(lastFrame()).toContain('step:variants'); - - act(() => ref.current!.setSkipCheck(s => s === 'agent')); - act(() => ref.current!.goBack()); - - expect(lastFrame()).toContain('step:gateway'); - }); - - it('advance skips multiple consecutive skippable steps', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setSkipCheck(s => s === 'agent' || s === 'variants')); - act(() => ref.current!.setName('T')); - act(() => ref.current!.setDescription('')); - act(() => ref.current!.setGateway({ type: 'create-new' })); - - expect(lastFrame()).toContain('step:onlineEval'); - }); - - it('skip check does not affect non-skippable steps', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setSkipCheck(() => false)); - act(() => ref.current!.setName('T')); - - expect(lastFrame()).toContain('step:description'); - }); - }); -}); diff --git a/src/cli/tui/screens/ab-test/__tests__/useTargetBasedWizard.test.tsx b/src/cli/tui/screens/ab-test/__tests__/useTargetBasedWizard.test.tsx deleted file mode 100644 index 4ea0a40d5..000000000 --- a/src/cli/tui/screens/ab-test/__tests__/useTargetBasedWizard.test.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import type { TargetInfo } from '../types'; -import { useTargetBasedWizard } from '../useTargetBasedWizard'; -import { Text } from 'ink'; -import { render } from 'ink-testing-library'; -import React, { act, useImperativeHandle } from 'react'; -import { describe, expect, it } from 'vitest'; - -// ── Simple harness ───────────────────────────────────────────────────────── - -function Harness() { - const wizard = useTargetBasedWizard(); - return ( - - step:{wizard.step} - name:{wizard.config.name} - description:{wizard.config.description} - gateway:{wizard.config.gateway} - controlWeight:{wizard.config.controlWeight} - treatmentWeight:{wizard.config.treatmentWeight} - enableOnCreate:{String(wizard.config.enableOnCreate)} - - ); -} - -// ── Imperative harness ───────────────────────────────────────────────────── - -interface HarnessHandle { - setName: (name: string) => void; - setDescription: (desc: string) => void; - advanceFromNameDescription: () => void; - setGateway: (name: string, isNew: boolean) => void; - advance: () => void; - goBack: () => void; - setControlTarget: (target: TargetInfo, isNew: boolean) => void; - setTreatmentTarget: (target: TargetInfo, isNew: boolean) => void; - setControlWeight: (w: number) => void; - setControlEval: (name: string) => void; - setTreatmentEval: (name: string) => void; - setEnableOnCreate: (enable: boolean) => void; - toAddABTestConfig: ReturnType['toAddABTestConfig']; -} - -const ImperativeHarness = React.forwardRef((_, ref) => { - const wizard = useTargetBasedWizard(); - useImperativeHandle(ref, () => ({ - setName: wizard.setName, - setDescription: wizard.setDescription, - advanceFromNameDescription: wizard.advanceFromNameDescription, - setGateway: wizard.setGateway, - advance: wizard.advance, - goBack: wizard.goBack, - setControlTarget: wizard.setControlTarget, - setTreatmentTarget: wizard.setTreatmentTarget, - setControlWeight: wizard.setControlWeight, - setControlEval: wizard.setControlEval, - setTreatmentEval: wizard.setTreatmentEval, - setEnableOnCreate: wizard.setEnableOnCreate, - toAddABTestConfig: wizard.toAddABTestConfig, - })); - const ctrlName = wizard.config.controlTargetInfo ? wizard.config.controlTargetInfo.name : 'null'; - const treatName = wizard.config.treatmentTargetInfo ? wizard.config.treatmentTargetInfo.name : 'null'; - return ( - - {[ - `step:${wizard.step}`, - `name:${wizard.config.name}`, - `description:${wizard.config.description}`, - `gateway:${wizard.config.gateway}`, - `gatewayIsNew:${String(wizard.config.gatewayIsNew)}`, - `controlWeight:${wizard.config.controlWeight}`, - `treatmentWeight:${wizard.config.treatmentWeight}`, - `controlOnlineEval:${wizard.config.controlOnlineEval}`, - `treatmentOnlineEval:${wizard.config.treatmentOnlineEval}`, - `enableOnCreate:${String(wizard.config.enableOnCreate)}`, - `controlTargetInfo:${ctrlName}`, - `treatmentTargetInfo:${treatName}`, - ].join('|')} - - ); -}); -ImperativeHarness.displayName = 'ImperativeHarness'; - -// ── Tests ────────────────────────────────────────────────────────────────── - -describe('useTargetBasedWizard', () => { - describe('defaults', () => { - it('initial step is nameDescription', () => { - const { lastFrame } = render(); - expect(lastFrame()).toContain('step:nameDescription'); - }); - - it('default weights are 90/10', () => { - const { lastFrame } = render(); - expect(lastFrame()).toContain('controlWeight:90'); - expect(lastFrame()).toContain('treatmentWeight:10'); - }); - - it('default enableOnCreate is true', () => { - const { lastFrame } = render(); - expect(lastFrame()).toContain('enableOnCreate:true'); - }); - }); - - describe('step navigation', () => { - it('advanceFromNameDescription moves to gateway step', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.advanceFromNameDescription()); - - expect(lastFrame()).toContain('step:gateway'); - }); - - it('advance from gateway moves to builder', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.advanceFromNameDescription()); - // setGateway auto-advances to builder - act(() => ref.current!.setGateway('my-gw', false)); - - expect(lastFrame()).toContain('step:builder'); - }); - - it('advance from builder moves to enableOnCreate', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.advanceFromNameDescription()); - act(() => ref.current!.setGateway('my-gw', false)); - // Now at builder, advance to enableOnCreate - act(() => ref.current!.advance()); - - expect(lastFrame()).toContain('step:enableOnCreate'); - }); - - it('advance from enableOnCreate moves to confirm', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.advanceFromNameDescription()); - act(() => ref.current!.setGateway('my-gw', false)); - act(() => ref.current!.advance()); // builder → enableOnCreate - act(() => ref.current!.setEnableOnCreate(true)); // enableOnCreate → confirm - - expect(lastFrame()).toContain('step:confirm'); - }); - }); - - describe('goBack', () => { - it('goBack from gateway goes to nameDescription', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.advanceFromNameDescription()); - expect(lastFrame()).toContain('step:gateway'); - - act(() => ref.current!.goBack()); - expect(lastFrame()).toContain('step:nameDescription'); - }); - - it('goBack from builder goes to gateway', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.advanceFromNameDescription()); - act(() => ref.current!.setGateway('my-gw', false)); - expect(lastFrame()).toContain('step:builder'); - - act(() => ref.current!.goBack()); - expect(lastFrame()).toContain('step:gateway'); - }); - }); - - describe('config updates', () => { - it('setName updates config', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setName('MyTest')); - - expect(lastFrame()).toContain('name:MyTest'); - }); - - it('setDescription updates config', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setDescription('desc1')); - - expect(lastFrame()).toContain('description:desc1'); - }); - - it('setGateway updates config', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.advanceFromNameDescription()); - act(() => ref.current!.setGateway('gw-123', true)); - - expect(lastFrame()).toContain('gateway:gw-123'); - expect(lastFrame()).toContain('gatewayIsNew:true'); - }); - - it('setControlTarget updates config with targetInfo', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - const target: TargetInfo = { name: 'ctrl-target', runtimeRef: 'arn:runtime:1', qualifier: 'DEFAULT' }; - act(() => ref.current!.setControlTarget(target, false)); - - expect(lastFrame()).toContain('controlTargetInfo:ctrl-target'); - }); - - it('setTreatmentTarget updates config with targetInfo', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - const target: TargetInfo = { name: 'tt1', runtimeRef: 'arn:runtime:2', qualifier: 'v2' }; - act(() => ref.current!.setTreatmentTarget(target, true)); - - const frame = lastFrame()!.replace(/\n/g, ''); - expect(frame).toContain('treatmentTargetInfo:tt1'); - }); - - it('setControlWeight updates config (sum to 100)', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setControlWeight(70)); - - const frame = lastFrame()!.replace(/\n/g, ''); - expect(frame).toContain('controlWeight:70'); - expect(frame).toContain('treatmentWeight:30'); - }); - - it('setControlEval updates config', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setControlEval('eval-arn-1')); - - expect(lastFrame()).toContain('controlOnlineEval:eval-arn-1'); - }); - - it('setTreatmentEval updates config', () => { - const ref = React.createRef(); - const { lastFrame } = render(); - - act(() => ref.current!.setTreatmentEval('eval-arn-2')); - - expect(lastFrame()).toContain('treatmentOnlineEval:eval-arn-2'); - }); - }); - - describe('toAddABTestConfig', () => { - it('returns correct AddABTestConfig shape', () => { - const ref = React.createRef(); - render(); - - const controlTarget: TargetInfo = { name: 'ctrl', runtimeRef: 'arn:runtime:1', qualifier: 'DEFAULT' }; - const treatmentTarget: TargetInfo = { name: 'treat', runtimeRef: 'arn:runtime:2', qualifier: 'v2' }; - - act(() => ref.current!.setName('TestAB')); - act(() => ref.current!.setDescription('A/B test')); - act(() => ref.current!.advanceFromNameDescription()); - act(() => ref.current!.setGateway('my-gateway', false)); - act(() => ref.current!.setControlTarget(controlTarget, false)); - act(() => ref.current!.setTreatmentTarget(treatmentTarget, true)); - act(() => ref.current!.setControlWeight(80)); - act(() => ref.current!.setControlEval('eval-1')); - act(() => ref.current!.setTreatmentEval('eval-2')); - - let config: ReturnType | undefined; - act(() => { - config = ref.current!.toAddABTestConfig(); - }); - - expect(config).toBeDefined(); - expect(config!.mode).toBe('target-based'); - expect(config!.name).toBe('TestAB'); - expect(config!.description).toBe('A/B test'); - expect(config!.gateway).toBe('my-gateway'); - expect(config!.gatewayIsNew).toBe(false); - expect(config!.gatewayChoice).toEqual({ type: 'existing-http', name: 'my-gateway' }); - expect(config!.controlTargetInfo).toEqual(controlTarget); - expect(config!.controlTargetIsNew).toBe(false); - expect(config!.treatmentTargetInfo).toEqual(treatmentTarget); - expect(config!.treatmentTargetIsNew).toBe(true); - expect(config!.controlWeight).toBe(80); - expect(config!.treatmentWeight).toBe(20); - expect(config!.controlOnlineEval).toBe('eval-1'); - expect(config!.treatmentOnlineEval).toBe('eval-2'); - expect(config!.runtime).toBe('arn:runtime:1'); - expect(config!.controlTarget).toBe('ctrl'); - expect(config!.controlEndpoint).toBe('DEFAULT'); - expect(config!.treatmentTarget).toBe('treat'); - expect(config!.treatmentEndpoint).toBe('v2'); - expect(config!.enableOnCreate).toBe(true); - expect(config!.evaluators).toEqual([]); - expect(config!.samplingRate).toBe(10); - }); - - it('returns create-new gatewayChoice when gatewayIsNew is true', () => { - const ref = React.createRef(); - render(); - - act(() => ref.current!.advanceFromNameDescription()); - act(() => ref.current!.setGateway('new-gw', true)); - - let config: ReturnType | undefined; - act(() => { - config = ref.current!.toAddABTestConfig(); - }); - - expect(config!.gatewayChoice).toEqual({ type: 'create-new' }); - }); - }); -}); diff --git a/src/cli/tui/screens/ab-test/index.ts b/src/cli/tui/screens/ab-test/index.ts deleted file mode 100644 index 162b24eb9..000000000 --- a/src/cli/tui/screens/ab-test/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { AddABTestFlow } from './AddABTestFlow'; -export { ABTestDetailScreen } from './ABTestDetailScreen'; -export { ABTestPickerScreen } from './ABTestPickerScreen'; -export { RemoveABTestScreen } from './RemoveABTestScreen'; diff --git a/src/cli/tui/screens/ab-test/types.ts b/src/cli/tui/screens/ab-test/types.ts deleted file mode 100644 index 977a2ca07..000000000 --- a/src/cli/tui/screens/ab-test/types.ts +++ /dev/null @@ -1,89 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// AB Test Wizard Types -// ───────────────────────────────────────────────────────────────────────────── - -export type ABTestMode = 'config-bundle' | 'target-based'; - -export type AddABTestStep = - | 'mode' - | 'name' - | 'description' - | 'agent' - | 'gateway' - | 'variants' - | 'controlTarget' - | 'treatmentTarget' - | 'weights' - | 'evalPath' - | 'evalSelect' - | 'evalCreate' - | 'evalSamplingRate' - | 'onlineEval' - | 'maxDuration' - | 'enableOnCreate' - | 'confirm'; - -export type GatewayChoice = { type: 'create-new' } | { type: 'existing-http'; name: string }; - -/** Rich target info for target-based AB testing. */ -export interface TargetInfo { - name: string; - runtimeRef: string; - qualifier: string; -} - -export interface AddABTestConfig { - mode: ABTestMode; - name: string; - description: string; - agent: string; - gatewayChoice: GatewayChoice; - // Config-bundle mode - controlBundle: string; - controlVersion: string; - treatmentBundle: string; - treatmentVersion: string; - treatmentWeight: number; - onlineEval: string; - // Target-based mode fields - gateway: string; - gatewayIsNew: boolean; - controlTargetInfo: TargetInfo | null; - controlTargetIsNew: boolean; - treatmentTargetInfo: TargetInfo | null; - treatmentTargetIsNew: boolean; - // Legacy target-based fields (populated from TargetInfo for downstream compatibility) - runtime: string; - controlTarget: string; - controlEndpoint: string; - treatmentTarget: string; - treatmentEndpoint: string; - controlWeight: number; - controlOnlineEval: string; - treatmentOnlineEval: string; - evaluators: string[]; - samplingRate: number; - // Shared - maxDuration: number | undefined; - enableOnCreate: boolean; -} - -export const AB_TEST_STEP_LABELS: Record = { - mode: 'Mode', - name: 'Name', - description: 'Description', - agent: 'Agent', - gateway: 'Gateway', - variants: 'Variants', - controlTarget: 'Control', - treatmentTarget: 'Treatment', - weights: 'Weights', - evalPath: 'Eval', - evalSelect: 'Eval', - evalCreate: 'Eval', - evalSamplingRate: 'Eval', - onlineEval: 'Eval', - maxDuration: 'Duration', - enableOnCreate: 'Enable', - confirm: 'Confirm', -}; diff --git a/src/cli/tui/screens/ab-test/useAddABTestWizard.ts b/src/cli/tui/screens/ab-test/useAddABTestWizard.ts deleted file mode 100644 index bb4fef0ad..000000000 --- a/src/cli/tui/screens/ab-test/useAddABTestWizard.ts +++ /dev/null @@ -1,324 +0,0 @@ -import type { VariantConfig } from './VariantConfigForm'; -import type { ABTestMode, AddABTestConfig, AddABTestStep, GatewayChoice, TargetInfo } from './types'; -import { useCallback, useRef, useState } from 'react'; - -const CONFIG_BUNDLE_STEPS: AddABTestStep[] = [ - 'mode', - 'name', - 'description', - 'gateway', - 'agent', - 'variants', - 'onlineEval', - 'maxDuration', - 'enableOnCreate', - 'confirm', -]; - -const TARGET_BASED_STEPS: AddABTestStep[] = [ - 'mode', - 'name', - 'description', - 'gateway', - 'controlTarget', - 'treatmentTarget', - 'weights', - 'evalSelect', - 'enableOnCreate', - 'confirm', -]; - -function getDefaultConfig(): AddABTestConfig { - return { - mode: 'config-bundle', - name: '', - description: '', - agent: '', - gatewayChoice: { type: 'create-new' }, - controlBundle: '', - controlVersion: '', - treatmentBundle: '', - treatmentVersion: '', - treatmentWeight: 20, - onlineEval: '', - // Target-based mode fields - gateway: '', - gatewayIsNew: false, - controlTargetInfo: null, - controlTargetIsNew: false, - treatmentTargetInfo: null, - treatmentTargetIsNew: false, - // Legacy target-based fields - runtime: '', - controlTarget: '', - controlEndpoint: '', - treatmentTarget: '', - treatmentEndpoint: '', - controlWeight: 90, - controlOnlineEval: '', - treatmentOnlineEval: '', - evaluators: [], - samplingRate: 10, - maxDuration: undefined, - enableOnCreate: true, - }; -} - -export type StepSkipCheck = (step: AddABTestStep) => boolean; - -export function useAddABTestWizard() { - const [config, setConfig] = useState(getDefaultConfig); - const [step, setStep] = useState('mode'); - const skipCheckRef = useRef(() => false); - - const getSteps = useCallback((): AddABTestStep[] => { - return config.mode === 'target-based' ? TARGET_BASED_STEPS : CONFIG_BUNDLE_STEPS; - }, [config.mode]); - - const currentIndex = getSteps().indexOf(step); - - const setSkipCheck = useCallback((check: StepSkipCheck) => { - skipCheckRef.current = check; - }, []); - - const goBack = useCallback(() => { - const steps = getSteps(); - for (let i = currentIndex - 1; i >= 0; i--) { - if (!skipCheckRef.current(steps[i]!)) { - setStep(steps[i]!); - return; - } - } - }, [currentIndex, getSteps]); - - const nextStep = useCallback( - (currentStepName: AddABTestStep): AddABTestStep | undefined => { - const steps = getSteps(); - const idx = steps.indexOf(currentStepName); - for (let i = idx + 1; i < steps.length; i++) { - if (!skipCheckRef.current(steps[i]!)) { - return steps[i]!; - } - } - return undefined; - }, - [getSteps] - ); - - const advance = useCallback( - (from: AddABTestStep) => { - const next = nextStep(from); - if (next) setStep(next); - }, - [nextStep] - ); - - const setMode = useCallback( - (mode: ABTestMode) => { - setConfig(c => ({ ...c, mode })); - advance('mode'); - }, - [advance] - ); - - const setName = useCallback( - (name: string) => { - setConfig(c => ({ ...c, name })); - advance('name'); - }, - [advance] - ); - - const setDescription = useCallback( - (description: string) => { - setConfig(c => ({ ...c, description })); - advance('description'); - }, - [advance] - ); - - const setAgent = useCallback( - (agent: string) => { - setConfig(c => ({ ...c, agent })); - advance('agent'); - }, - [advance] - ); - - const setGateway = useCallback( - (gatewayChoice: GatewayChoice) => { - setConfig(c => ({ - ...c, - gatewayChoice, - gateway: gatewayChoice.type === 'existing-http' ? gatewayChoice.name : '', - gatewayIsNew: gatewayChoice.type === 'create-new', - })); - advance('gateway'); - }, - [advance] - ); - - const setGatewayWithName = useCallback( - (gatewayName: string, isNew: boolean) => { - const gatewayChoice: GatewayChoice = isNew - ? { type: 'create-new' } - : { type: 'existing-http', name: gatewayName }; - setConfig(c => ({ - ...c, - gatewayChoice, - gateway: gatewayName, - gatewayIsNew: isNew, - })); - advance('gateway'); - }, - [advance] - ); - - const setVariants = useCallback( - (variantConfig: VariantConfig) => { - setConfig(c => ({ - ...c, - controlBundle: variantConfig.controlBundle, - controlVersion: variantConfig.controlVersion, - treatmentBundle: variantConfig.treatmentBundle, - treatmentVersion: variantConfig.treatmentVersion, - treatmentWeight: variantConfig.treatmentWeight, - })); - advance('variants'); - }, - [advance] - ); - - const setOnlineEval = useCallback( - (onlineEval: string) => { - setConfig(c => ({ ...c, onlineEval })); - advance('onlineEval'); - }, - [advance] - ); - - // Target-based mode setters - - const setControlTarget = useCallback( - (target: TargetInfo, isNew: boolean) => { - setConfig(c => ({ - ...c, - controlTargetInfo: target, - controlTargetIsNew: isNew, - controlTarget: target.name, - controlEndpoint: target.qualifier, - runtime: target.runtimeRef, - })); - advance('controlTarget'); - }, - [advance] - ); - - const setTreatmentTarget = useCallback( - (target: TargetInfo, isNew: boolean) => { - setConfig(c => ({ - ...c, - treatmentTargetInfo: target, - treatmentTargetIsNew: isNew, - treatmentTarget: target.name, - treatmentEndpoint: target.qualifier, - // Keep runtime from control if already set, otherwise use treatment's - runtime: c.runtime || target.runtimeRef, - })); - advance('treatmentTarget'); - }, - [advance] - ); - - const setWeights = useCallback( - (controlWeight: number, treatmentWeight: number) => { - setConfig(c => ({ ...c, controlWeight, treatmentWeight })); - advance('weights'); - }, - [advance] - ); - - const setEvalPath = useCallback( - (path: 'select' | 'create') => { - if (path === 'select') { - advance('evalPath'); - } else { - // Skip evalSelect, go to evalCreate - setStep('evalCreate'); - } - }, - [advance] - ); - - const setEvalSelect = useCallback( - (controlEval: string, treatmentEval: string) => { - setConfig(c => ({ ...c, controlOnlineEval: controlEval, treatmentOnlineEval: treatmentEval })); - advance('evalSelect'); - }, - [advance] - ); - - const setEvaluators = useCallback( - (evaluators: string[]) => { - setConfig(c => ({ ...c, evaluators })); - advance('evalCreate'); - }, - [advance] - ); - - const setSamplingRate = useCallback( - (samplingRate: number) => { - setConfig(c => ({ ...c, samplingRate })); - advance('evalSamplingRate'); - }, - [advance] - ); - - const setMaxDuration = useCallback( - (maxDuration: number | undefined) => { - setConfig(c => ({ ...c, maxDuration })); - advance('maxDuration'); - }, - [advance] - ); - - const setEnableOnCreate = useCallback( - (enableOnCreate: boolean) => { - setConfig(c => ({ ...c, enableOnCreate })); - advance('enableOnCreate'); - }, - [advance] - ); - - const reset = useCallback(() => { - setConfig(getDefaultConfig()); - setStep('mode'); - }, []); - - return { - config, - step, - steps: getSteps(), - currentIndex, - goBack, - setSkipCheck, - setMode, - setName, - setDescription, - setAgent, - setGateway, - setGatewayWithName, - setVariants, - setOnlineEval, - setControlTarget, - setTreatmentTarget, - setWeights, - setEvalPath, - setEvalSelect, - setEvaluators, - setSamplingRate, - setMaxDuration, - setEnableOnCreate, - reset, - }; -} diff --git a/src/cli/tui/screens/ab-test/useTargetBasedWizard.ts b/src/cli/tui/screens/ab-test/useTargetBasedWizard.ts deleted file mode 100644 index 7c26474d8..000000000 --- a/src/cli/tui/screens/ab-test/useTargetBasedWizard.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { AddABTestConfig, GatewayChoice, TargetInfo } from './types'; -import { useCallback, useState } from 'react'; - -export type TargetBasedStep = 'nameDescription' | 'gateway' | 'builder' | 'enableOnCreate' | 'confirm'; - -export const TARGET_BASED_STEP_LABELS: Record = { - nameDescription: 'Name', - gateway: 'Gateway', - builder: 'Configure', - enableOnCreate: 'Enable', - confirm: 'Confirm', -}; - -const STEPS: TargetBasedStep[] = ['nameDescription', 'gateway', 'builder', 'enableOnCreate', 'confirm']; - -interface TargetBasedConfig { - name: string; - description: string; - gateway: string; - gatewayIsNew: boolean; - controlTargetInfo: TargetInfo | null; - controlTargetIsNew: boolean; - controlWeight: number; - controlOnlineEval: string; - treatmentTargetInfo: TargetInfo | null; - treatmentTargetIsNew: boolean; - treatmentWeight: number; - treatmentOnlineEval: string; - enableOnCreate: boolean; -} - -function getDefaultConfig(): TargetBasedConfig { - return { - name: '', - description: '', - gateway: '', - gatewayIsNew: false, - controlTargetInfo: null, - controlTargetIsNew: false, - controlWeight: 90, - controlOnlineEval: '', - treatmentTargetInfo: null, - treatmentTargetIsNew: false, - treatmentWeight: 10, - treatmentOnlineEval: '', - enableOnCreate: true, - }; -} - -export function useTargetBasedWizard() { - const [config, setConfig] = useState(getDefaultConfig); - const [step, setStep] = useState('nameDescription'); - - const currentIndex = STEPS.indexOf(step); - - const goBack = useCallback(() => { - const idx = STEPS.indexOf(step); - if (idx > 0) { - setStep(STEPS[idx - 1]!); - } - }, [step]); - - const advance = useCallback(() => { - const idx = STEPS.indexOf(step); - if (idx < STEPS.length - 1) { - setStep(STEPS[idx + 1]!); - } - }, [step]); - - const setName = useCallback((name: string) => { - setConfig(c => ({ ...c, name })); - }, []); - - const setDescription = useCallback((description: string) => { - setConfig(c => ({ ...c, description })); - }, []); - - const advanceFromNameDescription = useCallback(() => { - setStep('gateway'); - }, []); - - const setGateway = useCallback((name: string, isNew: boolean) => { - setConfig(c => ({ ...c, gateway: name, gatewayIsNew: isNew })); - // Auto-advance to builder - setStep('builder'); - }, []); - - const setControlTarget = useCallback((target: TargetInfo, isNew: boolean) => { - setConfig(c => ({ - ...c, - controlTargetInfo: target, - controlTargetIsNew: isNew, - })); - }, []); - - const setTreatmentTarget = useCallback((target: TargetInfo, isNew: boolean) => { - setConfig(c => ({ - ...c, - treatmentTargetInfo: target, - treatmentTargetIsNew: isNew, - })); - }, []); - - const setControlWeight = useCallback((w: number) => { - setConfig(c => ({ ...c, controlWeight: w, treatmentWeight: 100 - w })); - }, []); - - const setControlEval = useCallback((name: string) => { - setConfig(c => ({ ...c, controlOnlineEval: name })); - }, []); - - const setTreatmentEval = useCallback((name: string) => { - setConfig(c => ({ ...c, treatmentOnlineEval: name })); - }, []); - - const setEnableOnCreate = useCallback((enableOnCreate: boolean) => { - setConfig(c => ({ ...c, enableOnCreate })); - setStep('confirm'); - }, []); - - const isBuilderComplete = - config.controlTargetInfo !== null && - config.treatmentTargetInfo !== null && - config.controlWeight > 0 && - config.treatmentWeight > 0; - - const toAddABTestConfig = useCallback((): AddABTestConfig => { - const gatewayChoice: GatewayChoice = config.gatewayIsNew - ? { type: 'create-new' } - : { type: 'existing-http', name: config.gateway }; - - return { - mode: 'target-based', - name: config.name, - description: config.description, - agent: '', - gatewayChoice, - // Config-bundle fields (safe defaults) - controlBundle: '', - controlVersion: '', - treatmentBundle: '', - treatmentVersion: '', - treatmentWeight: config.treatmentWeight, - onlineEval: '', - // Target-based fields - gateway: config.gateway, - gatewayIsNew: config.gatewayIsNew, - controlTargetInfo: config.controlTargetInfo, - controlTargetIsNew: config.controlTargetIsNew, - treatmentTargetInfo: config.treatmentTargetInfo, - treatmentTargetIsNew: config.treatmentTargetIsNew, - // Legacy target-based fields - runtime: config.controlTargetInfo?.runtimeRef ?? '', - controlTarget: config.controlTargetInfo?.name ?? '', - controlEndpoint: config.controlTargetInfo?.qualifier ?? '', - treatmentTarget: config.treatmentTargetInfo?.name ?? '', - treatmentEndpoint: config.treatmentTargetInfo?.qualifier ?? '', - controlWeight: config.controlWeight, - controlOnlineEval: config.controlOnlineEval, - treatmentOnlineEval: config.treatmentOnlineEval, - evaluators: [], - samplingRate: 10, - maxDuration: undefined, - enableOnCreate: config.enableOnCreate, - }; - }, [config]); - - return { - config, - step, - steps: STEPS, - currentIndex, - goBack, - advance, - setName, - setDescription, - advanceFromNameDescription, - setGateway, - setControlTarget, - setTreatmentTarget, - setControlWeight, - setControlEval, - setTreatmentEval, - setEnableOnCreate, - isBuilderComplete, - toAddABTestConfig, - }; -} diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index 02995be50..8f25be054 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -3,7 +3,6 @@ import { VPC_ENDPOINT_WARNING } from '../../../commands/shared/vpc-utils'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; import { ErrorPrompt } from '../../components'; import { useAvailableAgents } from '../../hooks/useCreateMcp'; -import { AddABTestFlow } from '../ab-test'; import { AddAgentFlow } from '../agent/AddAgentFlow'; import type { AddAgentConfig } from '../agent/types'; import { FRAMEWORK_OPTIONS } from '../agent/types'; @@ -13,12 +12,15 @@ import { AddDatasetFlow } from '../dataset'; import { AddEvaluatorFlow } from '../evaluator'; import { AddHarnessFlow } from '../harness/AddHarnessFlow'; import { AddIdentityFlow } from '../identity'; +import { AddKnowledgeBaseFlow } from '../knowledge-base'; import { AddGatewayFlow, AddGatewayTargetFlow } from '../mcp'; import { AddMemoryFlow } from '../memory/AddMemoryFlow'; import { AddOnlineEvalFlow } from '../online-eval'; +import { AddOnlineInsightsFlow } from '../online-insights'; import { AddPaymentFlow } from '../payment'; import { AddPolicyFlow } from '../policy'; import { AddRuntimeEndpointFlow } from '../runtime-endpoint'; +import { AddWebSearchFlow } from '../web-search'; import type { AddResourceType } from './AddScreen'; import { AddScreen } from './AddScreen'; import { AddSuccessScreen } from './AddSuccessScreen'; @@ -33,13 +35,15 @@ type FlowState = | { name: 'gateway-wizard' } | { name: 'tool-wizard' } | { name: 'memory-wizard' } + | { name: 'knowledge-base-wizard' } + | { name: 'web-search-wizard' } | { name: 'identity-wizard' } | { name: 'evaluator-wizard' } | { name: 'online-eval-wizard' } + | { name: 'online-insights-wizard' } | { name: 'policy-wizard' } | { name: 'dataset-wizard' } | { name: 'config-bundle-wizard' } - | { name: 'ab-test-wizard' } | { name: 'runtime-endpoint-wizard' } | { name: 'payment-manager-wizard' } | { name: 'payment-connector-wizard' } @@ -186,12 +190,18 @@ function getInitialFlowState(resource?: AddResourceType): FlowState { return { name: 'tool-wizard' }; case 'memory': return { name: 'memory-wizard' }; + case 'knowledge-base': + return { name: 'knowledge-base-wizard' }; + case 'web-search': + return { name: 'web-search-wizard' }; case 'credential': return { name: 'identity-wizard' }; case 'evaluator': return { name: 'evaluator-wizard' }; case 'online-eval': return { name: 'online-eval-wizard' }; + case 'online-insights': + return { name: 'online-insights-wizard' }; case 'policy': return { name: 'policy-wizard' }; case 'runtime-endpoint': @@ -200,8 +210,6 @@ function getInitialFlowState(resource?: AddResourceType): FlowState { return { name: 'dataset-wizard' }; case 'config-bundle': return { name: 'config-bundle-wizard' }; - case 'ab-test': - return { name: 'ab-test-wizard' }; case 'payment-manager': return { name: 'payment-manager-wizard' }; case 'payment-connector': @@ -244,6 +252,12 @@ export function AddFlow(props: AddFlowProps) { case 'memory': setFlow({ name: 'memory-wizard' }); break; + case 'knowledge-base': + setFlow({ name: 'knowledge-base-wizard' }); + break; + case 'web-search': + setFlow({ name: 'web-search-wizard' }); + break; case 'credential': setFlow({ name: 'identity-wizard' }); break; @@ -253,6 +267,9 @@ export function AddFlow(props: AddFlowProps) { case 'online-eval': setFlow({ name: 'online-eval-wizard' }); break; + case 'online-insights': + setFlow({ name: 'online-insights-wizard' }); + break; case 'policy': setFlow({ name: 'policy-wizard' }); break; @@ -262,9 +279,6 @@ export function AddFlow(props: AddFlowProps) { case 'config-bundle': setFlow({ name: 'config-bundle-wizard' }); break; - case 'ab-test': - setFlow({ name: 'ab-test-wizard' }); - break; case 'runtime-endpoint': setFlow({ name: 'runtime-endpoint-wizard' }); break; @@ -474,6 +488,32 @@ export function AddFlow(props: AddFlowProps) { ); } + // Knowledge base wizard + if (flow.name === 'knowledge-base-wizard') { + return ( + setFlow({ name: 'select' })} + onExit={props.onExit} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + + // Web search wizard + if (flow.name === 'web-search-wizard') { + return ( + setFlow({ name: 'select' })} + onExit={props.onExit} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + // Identity wizard - now uses AddIdentityFlow with mode selection if (flow.name === 'identity-wizard') { return ( @@ -513,10 +553,10 @@ export function AddFlow(props: AddFlowProps) { ); } - // Policy wizard - picker for policy engine vs policy, then wizard - if (flow.name === 'policy-wizard') { + // Online insights wizard + if (flow.name === 'online-insights-wizard') { return ( - setFlow({ name: 'select' })} @@ -526,10 +566,10 @@ export function AddFlow(props: AddFlowProps) { ); } - // Dataset wizard - if (flow.name === 'dataset-wizard') { + // Policy wizard - picker for policy engine vs policy, then wizard + if (flow.name === 'policy-wizard') { return ( - setFlow({ name: 'select' })} @@ -539,10 +579,10 @@ export function AddFlow(props: AddFlowProps) { ); } - // Configuration bundle wizard - if (flow.name === 'config-bundle-wizard') { + // Dataset wizard + if (flow.name === 'dataset-wizard') { return ( - setFlow({ name: 'select' })} @@ -552,10 +592,10 @@ export function AddFlow(props: AddFlowProps) { ); } - // AB test wizard - if (flow.name === 'ab-test-wizard') { + // Configuration bundle wizard + if (flow.name === 'config-bundle-wizard') { return ( - setFlow({ name: 'select' })} diff --git a/src/cli/tui/screens/add/AddScreen.tsx b/src/cli/tui/screens/add/AddScreen.tsx index a05db1ee1..aca3e59a8 100644 --- a/src/cli/tui/screens/add/AddScreen.tsx +++ b/src/cli/tui/screens/add/AddScreen.tsx @@ -1,4 +1,4 @@ -import { isPreviewEnabled } from '../../../feature-flags'; +import { isGatedFeaturesEnabled, isPreviewEnabled } from '../../../feature-flags'; import type { SelectableItem } from '../../components'; import { SelectScreen } from '../../components'; @@ -6,15 +6,17 @@ export type AddResourceType = | 'harness' | 'agent' | 'memory' + | 'knowledge-base' + | 'web-search' | 'credential' | 'evaluator' | 'online-eval' + | 'online-insights' | 'gateway' | 'gateway-target' | 'runtime-endpoint' | 'policy' | 'config-bundle' - | 'ab-test' | 'dataset' | 'payment-manager' | 'payment-connector'; @@ -22,20 +24,22 @@ export type AddResourceType = const BASE_ADD_RESOURCES: { id: AddResourceType; title: string; description: string }[] = [ { id: 'agent', title: 'Agent', description: 'Deploy an HTTP, MCP, A2A, or AG-UI agent' }, { id: 'memory', title: 'Memory', description: 'Persistent context storage' }, + { id: 'knowledge-base', title: 'Knowledge Base', description: 'Create a managed knowledge base for retrieval' }, + { id: 'web-search', title: 'Web Search', description: 'Wire the Amazon Web Search managed connector to a gateway' }, { id: 'credential', title: 'Credential', description: 'API key credential providers' }, { id: 'evaluator', title: 'Evaluator', description: 'Custom LLM-as-a-Judge evaluator' }, { id: 'online-eval', title: 'Online Eval Config', description: 'Continuous evaluation pipeline' }, + { id: 'online-insights', title: 'Online Insights [preview]', description: 'Continuous failure analysis pipeline' }, { id: 'gateway', title: 'Gateway', description: 'Route and manage gateway targets' }, { id: 'gateway-target', title: 'Gateway Target', description: 'Extend agent capabilities' }, { id: 'runtime-endpoint', title: 'Runtime Endpoint', description: 'Named endpoint for a runtime' }, { id: 'policy', title: 'Policy', description: 'Cedar policies for gateway tools' }, { id: 'dataset', title: 'Dataset', description: 'Evaluation dataset for testing agents' }, - { id: 'config-bundle', title: 'Configuration Bundle [preview]', description: 'Versioned component configurations' }, - { id: 'ab-test', title: 'AB Test [preview]', description: 'Compare agent configurations with traffic splitting' }, - { id: 'payment-manager', title: 'Payment Manager', description: 'x402 crypto microtransactions config' }, + { id: 'config-bundle', title: 'Configuration Bundle', description: 'Versioned component configurations' }, + { id: 'payment-manager', title: 'Payment Manager [preview]', description: 'x402 crypto microtransactions config' }, { id: 'payment-connector', - title: 'Payment Connector', + title: 'Payment Connector [preview]', description: 'Link payment provider credentials to a manager', }, ]; @@ -47,11 +51,14 @@ const ADD_RESOURCES: { id: AddResourceType; title: string; description: string } ...BASE_ADD_RESOURCES, ]; -const ADD_RESOURCE_ITEMS: SelectableItem[] = ADD_RESOURCES.map(r => ({ - ...r, - disabled: false, - description: r.description, -})); +const ADD_RESOURCE_ITEMS: SelectableItem[] = ADD_RESOURCES.map(r => { + const gated = (r.id === 'knowledge-base' || r.id === 'web-search') && !isGatedFeaturesEnabled(); + return { + ...r, + disabled: gated, + description: gated ? 'Coming soon' : r.description, + }; +}); interface AddScreenProps { onSelect: (resourceType: AddResourceType) => void; @@ -65,7 +72,12 @@ export function AddScreen({ onSelect, onExit }: AddScreenProps) { onSelect(item.id as AddResourceType)} + onSelect={item => { + // Safe: ADD_RESOURCE_ITEMS is built from ADD_RESOURCES whose ids are + // typed as AddResourceType. + const resource = ADD_RESOURCES.find(r => r.id === item.id); + if (resource) onSelect(resource.id); + }} onExit={onExit} isDisabled={isDisabled} /> diff --git a/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx index d1592e14f..7ca2a3cb7 100644 --- a/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx +++ b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx @@ -1,9 +1,18 @@ import { AddScreen } from '../AddScreen.js'; import { render } from 'ink-testing-library'; import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('AddScreen', () => { + const originalGate = process.env.ENABLE_GATED_FEATURES; + beforeEach(() => { + process.env.ENABLE_GATED_FEATURES = '1'; + }); + afterEach(() => { + if (originalGate === undefined) delete process.env.ENABLE_GATED_FEATURES; + else process.env.ENABLE_GATED_FEATURES = originalGate; + }); + it('gateway and gateway-target options are present and not disabled', () => { const onSelect = vi.fn(); const onExit = vi.fn(); @@ -23,4 +32,15 @@ describe('AddScreen', () => { expect(lastFrame()).toContain('Payment Manager'); expect(lastFrame()).toContain('Payment Connector'); }); + + it('Web Search option shows Coming soon when ENABLE_GATED_FEATURES is unset', () => { + delete process.env.ENABLE_GATED_FEATURES; + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Web Search'); + expect(lastFrame()).toContain('Coming soon'); + }); }); diff --git a/src/cli/tui/screens/agent/AddAgentScreen.tsx b/src/cli/tui/screens/agent/AddAgentScreen.tsx index 757af7e9c..ad52b60f1 100644 --- a/src/cli/tui/screens/agent/AddAgentScreen.tsx +++ b/src/cli/tui/screens/agent/AddAgentScreen.tsx @@ -599,7 +599,10 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg resetByoFilesystemState(); } // Config bundle has no sub-steps — set flag immediately - setByoConfig(c => ({ ...c, withConfigBundle: selected.has('configBundle') || undefined })); + setByoConfig(c => ({ + ...c, + withConfigBundle: selected.has('configBundle') || undefined, + })); // Navigate to first advanced sub-step (steps memo hasn't updated yet) setTimeout(() => { if (selected.has('dockerfile') && byoConfig.buildType === 'Container') { diff --git a/src/cli/tui/screens/config-bundle-hub/ConfigBundleHubScreen.tsx b/src/cli/tui/screens/config-bundle-hub/ConfigBundleHubScreen.tsx index 476336dd0..88428161e 100644 --- a/src/cli/tui/screens/config-bundle-hub/ConfigBundleHubScreen.tsx +++ b/src/cli/tui/screens/config-bundle-hub/ConfigBundleHubScreen.tsx @@ -46,7 +46,7 @@ export function ConfigBundleHubScreen({ onSelectBundle, onExit }: ConfigBundleHu if (isLoading) { return ( - + Loading configuration bundles... ); @@ -54,7 +54,7 @@ export function ConfigBundleHubScreen({ onSelectBundle, onExit }: ConfigBundleHu if (error) { return ( - + Error: {error} ); @@ -62,7 +62,7 @@ export function ConfigBundleHubScreen({ onSelectBundle, onExit }: ConfigBundleHu if (bundles.length === 0) { return ( - + No configuration bundles found. Use `agentcore add config-bundle` to create one, then deploy. @@ -81,7 +81,7 @@ export function ConfigBundleHubScreen({ onSelectBundle, onExit }: ConfigBundleHu return ( Description: {bundle.description} )} + {bundle.createdAt && ( + + {' '} + Created: {formatRelativeTime(String(bundle.createdAt))} + + )} {bundle.lastUpdated && ( {' '} diff --git a/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts index c0276ae53..5cf7ca89b 100644 --- a/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts +++ b/src/cli/tui/screens/config-bundle-hub/useConfigBundleHub.ts @@ -18,6 +18,7 @@ export interface BundleWithMeta { description?: string; versionCount: number; branches: string[]; + createdAt?: number; lastUpdated?: string; } @@ -73,10 +74,20 @@ export function useConfigBundleHub(): ConfigBundleHubState { const deployedBundles = Object.values(deployedState.targets).find(t => t.resources?.configBundles)?.resources?.configBundles ?? {}; + // Fetch all bundles from the list API once to get createdAt for each + let allBundlesList: Awaited> | undefined; + try { + allBundlesList = await listConfigurationBundles({ region: resolvedRegion, maxResults: 100 }); + } catch { + // Non-critical — continue without createdAt + } + // Build bundle list from project config, enriching with deployed version info const enriched = await Promise.all( projectBundles.map(async (bundleSpec): Promise => { const deployed = deployedBundles[bundleSpec.name]; + const nameVariants = getBundleNameVariants(bundleSpec.name, projectSpec.name); + const listMatch = allBundlesList?.bundles.find(b => nameVariants.includes(b.bundleName)); if (!deployed) { // Not yet deployed — show from project config only return { @@ -86,6 +97,7 @@ export function useConfigBundleHub(): ConfigBundleHubState { description: bundleSpec.description, versionCount: 0, branches: bundleSpec.branchName ? [bundleSpec.branchName] : [], + createdAt: listMatch?.createdAt, }; } @@ -113,12 +125,14 @@ export function useConfigBundleHub(): ConfigBundleHubState { description: bundleSpec.description, versionCount: versions.versions.length, branches: [...branchSet], + createdAt: listMatch?.createdAt, lastUpdated: latestTs || undefined, }; } catch { // Stale deployed-state ID — try to resolve via list API try { - const allBundles = await listConfigurationBundles({ region: resolvedRegion, maxResults: 100 }); + const allBundles = + allBundlesList ?? (await listConfigurationBundles({ region: resolvedRegion, maxResults: 100 })); const nameVariants = getBundleNameVariants(bundleSpec.name, projectSpec.name); const match = allBundles.bundles.find(b => nameVariants.includes(b.bundleName)); if (match) { @@ -142,6 +156,7 @@ export function useConfigBundleHub(): ConfigBundleHubState { description: bundleSpec.description, versionCount: versions.versions.length, branches: [...branchSet], + createdAt: match.createdAt, lastUpdated: latestTs || undefined, }; } diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx index 2ccc9fab2..511265ece 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx @@ -50,7 +50,7 @@ export function AddConfigBundleFlow({ deployedArns.add(name); } } - const httpGateways = target.resources?.httpGateways; + const httpGateways = target.resources?.gateways; if (httpGateways) { for (const [name, state] of Object.entries(httpGateways)) { components.push({ name, arn: state.gatewayArn, type: 'gateway' }); @@ -75,7 +75,7 @@ export function AddConfigBundleFlow({ }); } } - for (const gw of projectSpec.httpGateways ?? []) { + for (const gw of (projectSpec.agentCoreGateways ?? []).filter(g => g.protocolType === 'None')) { if (!deployedArns.has(gw.name)) { components.push({ name: gw.name, diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx index 47d33ddf5..aab3b555a 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -4,6 +4,7 @@ import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; +import { COMPONENT_KEY_ERROR, COMPONENT_KEY_PATTERN } from './constants'; import type { AddConfigBundleConfig, ComponentType, DeployedComponent } from './types'; import { COMPONENT_TYPE_OPTIONS, CONFIG_BUNDLE_STEP_LABELS } from './types'; import { useAddConfigBundleWizard } from './useAddConfigBundleWizard'; @@ -17,6 +18,10 @@ interface AddConfigBundleScreenProps { deployedComponents: DeployedComponent[]; } +function validateComponentArn(value: string): string | true { + return COMPONENT_KEY_PATTERN.test(value) || COMPONENT_KEY_ERROR; +} + function validateConfigJson(value: string): string | true { try { const parsed: unknown = JSON.parse(value); @@ -71,6 +76,7 @@ export function AddConfigBundleScreen({ const isDescriptionStep = wizard.step === 'description'; const isComponentTypeStep = wizard.step === 'componentType'; const isComponentSelectStep = wizard.step === 'componentSelect'; + const isComponentArnEntryStep = wizard.step === 'componentArnEntry'; const isConfigurationStep = wizard.step === 'configuration'; const isAddAnotherStep = wizard.step === 'addAnother'; const isBranchNameStep = wizard.step === 'branchName'; @@ -185,6 +191,24 @@ export function AddConfigBundleScreen({ )} + {isComponentArnEntryStep && ( + <> + + Enter the component ARN + The resource this configuration applies to (e.g. a gateway target). + + wizard.goBack()} + customValidation={validateComponentArn} + /> + + )} + {isConfigurationStep && ( <> diff --git a/src/cli/tui/screens/config-bundle/__tests__/useAddConfigBundleWizard.test.tsx b/src/cli/tui/screens/config-bundle/__tests__/useAddConfigBundleWizard.test.tsx new file mode 100644 index 000000000..198c22f5c --- /dev/null +++ b/src/cli/tui/screens/config-bundle/__tests__/useAddConfigBundleWizard.test.tsx @@ -0,0 +1,167 @@ +import { COMPONENT_KEY_PATTERN } from '../constants'; +import { useAddConfigBundleWizard } from '../useAddConfigBundleWizard'; +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; +import React, { act, useImperativeHandle } from 'react'; +import { describe, expect, it } from 'vitest'; + +type WizardReturn = ReturnType; + +interface HarnessHandle { + wizard: WizardReturn; +} + +const Harness = React.forwardRef((_props, ref) => { + const wizard = useAddConfigBundleWizard(); + useImperativeHandle(ref, () => ({ wizard })); + return step:{wizard.step}; +}); +Harness.displayName = 'Harness'; + +function setup() { + const ref = React.createRef(); + const result = render(); + return { ref, ...result }; +} + +/** Drive the wizard forward to the addAnother step with one component configured. */ +function advanceToAddAnother(ref: React.RefObject) { + act(() => ref.current!.wizard.setName('myBundle')); + act(() => ref.current!.wizard.setDescription('desc')); + act(() => ref.current!.wizard.setComponentType('runtime')); + act(() => ref.current!.wizard.setSelectedComponent('arn:aws:runtime/r1')); + act(() => ref.current!.wizard.setConfiguration({ systemPrompt: 'hi' })); +} + +describe('useAddConfigBundleWizard — add-another back-navigation (BUG TUI-B)', () => { + it('reaches addAnother after configuring one component', () => { + const { ref } = setup(); + advanceToAddAnother(ref); + expect(ref.current!.wizard.step).toBe('addAnother'); + }); + + it('back from a re-entered componentType returns to addAnother, not description', () => { + const { ref } = setup(); + advanceToAddAnother(ref); + + // User chooses "add another component" → re-enters componentType. + act(() => ref.current!.wizard.addAnotherComponent()); + expect(ref.current!.wizard.step).toBe('componentType'); + + // Backing out must return to the addAnother decision point (where "Continue" lives), + // NOT fall through the linear order to `description` (which would strip the Continue path). + act(() => ref.current!.wizard.goBack()); + expect(ref.current!.wizard.step).toBe('addAnother'); + + // And the already-configured component is preserved. + expect(Object.keys(ref.current!.wizard.config.components)).toHaveLength(1); + }); + + it('back from re-entered componentSelect returns to componentType, then to addAnother', () => { + const { ref } = setup(); + advanceToAddAnother(ref); + act(() => ref.current!.wizard.addAnotherComponent()); + act(() => ref.current!.wizard.setComponentType('runtime')); + expect(ref.current!.wizard.step).toBe('componentSelect'); + + act(() => ref.current!.wizard.goBack()); + expect(ref.current!.wizard.step).toBe('componentType'); + act(() => ref.current!.wizard.goBack()); + expect(ref.current!.wizard.step).toBe('addAnother'); + }); + + it('doneAddingComponents advances past components and clears the loop flag', () => { + const { ref } = setup(); + advanceToAddAnother(ref); + act(() => ref.current!.wizard.doneAddingComponents()); + // When ENABLE_GATED_FEATURES is off, branchName is skipped (defaults to mainline) + const expectedStep = process.env.ENABLE_GATED_FEATURES === '1' ? 'branchName' : 'commitMessage'; + expect(ref.current!.wizard.step).toBe(expectedStep); + + // Back from the current step follows the linear order, not the loop guard. + act(() => ref.current!.wizard.goBack()); + const expectedBackStep = process.env.ENABLE_GATED_FEATURES === '1' ? 'addAnother' : 'branchName'; + expect(ref.current!.wizard.step).toBe(expectedBackStep); + }); + + it('first-pass back-navigation is unaffected (componentType → description)', () => { + const { ref } = setup(); + act(() => ref.current!.wizard.setName('myBundle')); + act(() => ref.current!.wizard.setDescription('desc')); + expect(ref.current!.wizard.step).toBe('componentType'); + act(() => ref.current!.wizard.goBack()); + expect(ref.current!.wizard.step).toBe('description'); + }); +}); + +describe('useAddConfigBundleWizard — custom ARN component (Part 1)', () => { + /** Drive the wizard to the componentType step. */ + function advanceToComponentType(ref: React.RefObject) { + act(() => ref.current!.wizard.setName('myBundle')); + act(() => ref.current!.wizard.setDescription('desc')); + } + + const CUSTOM_ARN = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway-target/orders-Tg9xK2'; + + it('selecting custom routes componentType → componentArnEntry (not componentSelect)', () => { + const { ref } = setup(); + advanceToComponentType(ref); + act(() => ref.current!.wizard.setComponentType('custom')); + expect(ref.current!.wizard.step).toBe('componentArnEntry'); + expect(ref.current!.wizard.config.currentComponentType).toBe('custom'); + }); + + it('a pattern-valid ARN advances to configuration and is stored verbatim as currentComponentArn', () => { + const { ref } = setup(); + advanceToComponentType(ref); + act(() => ref.current!.wizard.setComponentType('custom')); + act(() => ref.current!.wizard.setCustomArn(CUSTOM_ARN)); + expect(ref.current!.wizard.step).toBe('configuration'); + expect(ref.current!.wizard.config.currentComponentArn).toBe(CUSTOM_ARN); + }); + + it('the custom component lands in config.components under the literal ARN key', () => { + const { ref } = setup(); + advanceToComponentType(ref); + act(() => ref.current!.wizard.setComponentType('custom')); + act(() => ref.current!.wizard.setCustomArn(CUSTOM_ARN)); + act(() => ref.current!.wizard.setConfiguration({ systemPrompt: 'hi' })); + expect(ref.current!.wizard.step).toBe('addAnother'); + expect(Object.keys(ref.current!.wizard.config.components)).toContain(CUSTOM_ARN); + expect(ref.current!.wizard.config.components[CUSTOM_ARN]).toEqual({ + configuration: { systemPrompt: 'hi' }, + }); + }); + + it('goBack from componentArnEntry returns to componentType', () => { + const { ref } = setup(); + advanceToComponentType(ref); + act(() => ref.current!.wizard.setComponentType('custom')); + expect(ref.current!.wizard.step).toBe('componentArnEntry'); + act(() => ref.current!.wizard.goBack()); + expect(ref.current!.wizard.step).toBe('componentType'); + }); + + it('runtime/gateway types still route to componentSelect (regression)', () => { + const { ref } = setup(); + advanceToComponentType(ref); + act(() => ref.current!.wizard.setComponentType('runtime')); + expect(ref.current!.wizard.step).toBe('componentSelect'); + + act(() => ref.current!.wizard.goBack()); + expect(ref.current!.wizard.step).toBe('componentType'); + act(() => ref.current!.wizard.setComponentType('gateway')); + expect(ref.current!.wizard.step).toBe('componentSelect'); + }); + + it('COMPONENT_KEY_PATTERN accepts ARNs and non-ARN identifiers, rejects placeholders/spaces/over-length', () => { + // Accept any pattern-valid string — an arn: prefix is NOT required (DECIDED). + expect(COMPONENT_KEY_PATTERN.test(CUSTOM_ARN)).toBe(true); + expect(COMPONENT_KEY_PATTERN.test('myComponentKey')).toBe(true); + // Reject placeholders, spaces, and over-length (>2048 chars). + expect(COMPONENT_KEY_PATTERN.test('{{runtime:MyAgent}}')).toBe(false); + expect(COMPONENT_KEY_PATTERN.test('not a valid arn!!')).toBe(false); + expect(COMPONENT_KEY_PATTERN.test('a'.repeat(2049))).toBe(false); + expect(COMPONENT_KEY_PATTERN.test('a'.repeat(2048))).toBe(true); + }); +}); diff --git a/src/cli/tui/screens/config-bundle/constants.ts b/src/cli/tui/screens/config-bundle/constants.ts new file mode 100644 index 000000000..0356545c8 --- /dev/null +++ b/src/cli/tui/screens/config-bundle/constants.ts @@ -0,0 +1,15 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Config Bundle Wizard Constants +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Service/CFN key pattern for a config-bundle component identifier. Mirrors + * `aws-bedrockagentcore-configurationbundle.json` `Components.patternProperties`. + * Any ARN qualifies, but an `arn:` prefix is NOT required — the service accepts + * any pattern-valid string (max 2048 chars). Rejects `{{...}}` placeholders, + * spaces, and over-length input — exactly what CloudFormation rejects. + */ +export const COMPONENT_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_:/.-]{0,2047}$/; + +/** Inline error shown when a custom component identifier fails {@link COMPONENT_KEY_PATTERN}. */ +export const COMPONENT_KEY_ERROR = 'Must be a valid component identifier (an ARN, max 2048 chars).'; diff --git a/src/cli/tui/screens/config-bundle/types.ts b/src/cli/tui/screens/config-bundle/types.ts index dba1ba4e7..aed0c56b9 100644 --- a/src/cli/tui/screens/config-bundle/types.ts +++ b/src/cli/tui/screens/config-bundle/types.ts @@ -9,13 +9,14 @@ export type AddConfigBundleStep = | 'description' | 'componentType' | 'componentSelect' + | 'componentArnEntry' | 'configuration' | 'addAnother' | 'branchName' | 'commitMessage' | 'confirm'; -export type ComponentType = 'runtime' | 'gateway'; +export type ComponentType = 'runtime' | 'gateway' | 'custom'; export interface DeployedComponent { name: string; @@ -44,6 +45,7 @@ export const CONFIG_BUNDLE_STEP_LABELS: Record = { description: 'Description', componentType: 'Type', componentSelect: 'Component', + componentArnEntry: 'ARN', configuration: 'Config', addAnother: 'More?', branchName: 'Branch', @@ -54,4 +56,5 @@ export const CONFIG_BUNDLE_STEP_LABELS: Record = { export const COMPONENT_TYPE_OPTIONS = [ { id: 'runtime', title: 'Agent Runtime', description: 'Configure an agent runtime' }, { id: 'gateway', title: 'HTTP Gateway', description: 'Configure an HTTP gateway' }, + { id: 'custom', title: 'Other (ARN)', description: 'Enter any component ARN (gateway target, etc.)' }, ] as const; diff --git a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts index daa79173b..96411db2f 100644 --- a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts +++ b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts @@ -1,4 +1,5 @@ import type { ComponentConfigurationMap } from '../../../../schema'; +import { isGatedFeaturesEnabled } from '../../../feature-flags'; import type { AddConfigBundleConfig, AddConfigBundleStep, ComponentType } from './types'; import { useCallback, useState } from 'react'; @@ -7,6 +8,7 @@ const ALL_STEPS: AddConfigBundleStep[] = [ 'description', 'componentType', 'componentSelect', + 'componentArnEntry', 'configuration', 'addAnother', 'branchName', @@ -28,13 +30,31 @@ function getDefaultConfig(): AddConfigBundleConfig { export function useAddConfigBundleWizard() { const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState('name'); + // True when the component-type/select steps were re-entered from the "add another?" loop, + // so back-navigation returns to the addAnother decision point (which holds "Continue" → + // branchName) instead of falling through the linear order back to `description`. Without this + // the user gets trapped: backing out of a second component drops all progress. + const [inAddAnotherLoop, setInAddAnotherLoop] = useState(false); const currentIndex = ALL_STEPS.indexOf(step); const goBack = useCallback(() => { + // The component picker (componentSelect) and the custom-ARN entry (componentArnEntry) are + // mutually exclusive branches off componentType — both return to componentType. + if (step === 'componentSelect' || step === 'componentArnEntry') { + setStep('componentType'); + return; + } + // If we're mid "add another component" loop, componentType must return to the addAnother step + // (where Continue lives), not to the linear previous step. + if (inAddAnotherLoop && step === 'componentType') { + setInAddAnotherLoop(false); + setStep('addAnother'); + return; + } const prevStep = ALL_STEPS[currentIndex - 1]; if (prevStep) setStep(prevStep); - }, [currentIndex]); + }, [currentIndex, inAddAnotherLoop, step]); const setName = useCallback((name: string) => { setConfig(c => ({ ...c, name })); @@ -48,7 +68,8 @@ export function useAddConfigBundleWizard() { const setComponentType = useCallback((componentType: ComponentType) => { setConfig(c => ({ ...c, currentComponentType: componentType, currentComponentArn: undefined })); - setStep('componentSelect'); + // Custom components are keyed by a free-text ARN; runtime/gateway pick from deployed resources. + setStep(componentType === 'custom' ? 'componentArnEntry' : 'componentSelect'); }, []); const setSelectedComponent = useCallback((arn: string) => { @@ -56,6 +77,11 @@ export function useAddConfigBundleWizard() { setStep('configuration'); }, []); + const setCustomArn = useCallback((arn: string) => { + setConfig(c => ({ ...c, currentComponentArn: arn })); + setStep('configuration'); + }, []); + const setConfiguration = useCallback((configuration: Record) => { setConfig(c => { const arn = c.currentComponentArn; @@ -71,11 +97,17 @@ export function useAddConfigBundleWizard() { const addAnotherComponent = useCallback(() => { setConfig(c => ({ ...c, currentComponentType: undefined, currentComponentArn: undefined })); + setInAddAnotherLoop(true); setStep('componentType'); }, []); const doneAddingComponents = useCallback(() => { - setStep('branchName'); + setInAddAnotherLoop(false); + if (isGatedFeaturesEnabled()) { + setStep('branchName'); + } else { + setStep('commitMessage'); + } }, []); const setBranchName = useCallback((branchName: string) => { @@ -90,6 +122,7 @@ export function useAddConfigBundleWizard() { const reset = useCallback(() => { setConfig(getDefaultConfig()); + setInAddAnotherLoop(false); setStep('name'); }, []); @@ -103,6 +136,7 @@ export function useAddConfigBundleWizard() { setDescription, setComponentType, setSelectedComponent, + setCustomArn, setConfiguration, addAnotherComponent, doneAddingComponents, diff --git a/src/cli/tui/screens/create/CreateScreen.tsx b/src/cli/tui/screens/create/CreateScreen.tsx index ac3ea6db6..d0fe55294 100644 --- a/src/cli/tui/screens/create/CreateScreen.tsx +++ b/src/cli/tui/screens/create/CreateScreen.tsx @@ -259,7 +259,7 @@ export function CreateScreen({ cwd, isInteractive, onExit, onNavigate }: CreateS const preview = isPreviewEnabled(); // Completion state for next steps - const allSuccess = !flow.hasError && flow.isComplete; + const allSuccess = !flow.hasError && flow.isComplete && flow.phase === 'complete'; // Handle exit - if successful, exit app completely and print completion screen const handleExit = useCallback(() => { diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index c76fab8ec..146498b12 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -671,6 +671,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { mcpName: addHarnessConfig.mcpName, mcpUrl: addHarnessConfig.mcpUrl, gatewayArn: addHarnessConfig.gatewayArn, + skills: addHarnessConfig.skills, authorizerType: addHarnessConfig.authorizerType, jwtConfig: addHarnessConfig.jwtConfig ? { diff --git a/src/cli/tui/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index 828aec38b..1c7a9c65e 100644 --- a/src/cli/tui/screens/deploy/DeployScreen.tsx +++ b/src/cli/tui/screens/deploy/DeployScreen.tsx @@ -76,6 +76,7 @@ export function DeployScreen({ diffSummaries, numStacksWithChanges, deployNotes, + managedMemoryNotice, postDeployWarnings, postDeployHasError, isDiffLoading, @@ -334,6 +335,14 @@ export function DeployScreen({ <> + {/* Managed-memory heads-up: shown while the slow CFN apply runs (not gated on success). + Styled as a plain dim "Note:" to match the transaction-search note convention below. */} + {managedMemoryNotice && !isComplete && ( + + Note: {managedMemoryNotice} + + )} + {/* Toggleable ResourceGraph view */} {showResourceGraph && context && ( diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 187f50ba7..a5a1ebc97 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -1,39 +1,39 @@ import { ConfigIO } from '../../../../lib'; -import type { DeployedState, HarnessDeployedState } from '../../../../schema'; import type { CdkToolkitWrapper, DeployMessage, SwitchableIoHost } from '../../../cdk/toolkit-lib'; import { buildDeployedState, getStackOutputs, parseAgentOutputs, + parseConfigBundleOutputs, parseDatasetOutputs, parseEvaluatorOutputs, parseGatewayOutputs, + parseHarnessOutputs, + parseKnowledgeBaseOutputs, parseMemoryOutputs, parseOnlineEvalOutputs, parsePaymentOutputs, parsePolicyEngineOutputs, parsePolicyOutputs, + parseRuntimeEndpointOutputs, } from '../../../cloudformation'; import { DEFAULT_DEPLOY_ATTRS, computeDeployAttrs } from '../../../commands/deploy/utils.js'; import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from '../../../errors'; import { isPreviewEnabled } from '../../../feature-flags'; import { ExecLogger } from '../../../logging'; import { + MANAGED_MEMORY_DEPLOY_NOTICE, cleanupPaymentCredentialProviders, + hasManagedMemoryHarness, performStackTeardown, setupTransactionSearch, } from '../../../operations/deploy'; import { computeProjectDeployHash } from '../../../operations/deploy/change-detection'; import { getGatewayTargetStatuses } from '../../../operations/deploy/gateway-status'; -import { createDeploymentManager } from '../../../operations/deploy/imperative'; -import { deleteOrphanedABTests, setupABTests } from '../../../operations/deploy/post-deploy-ab-tests'; -import { - resolveConfigBundleComponentKeys, - setupConfigBundles, -} from '../../../operations/deploy/post-deploy-config-bundles'; import { syncDatasets } from '../../../operations/deploy/post-deploy-datasets'; -import { setupHttpGateways } from '../../../operations/deploy/post-deploy-http-gateways'; +import { autoIngestKnowledgeBases } from '../../../operations/deploy/post-deploy-knowledge-bases'; import { enableOnlineEvalConfigs } from '../../../operations/deploy/post-deploy-online-evals'; +import { hydrateKnowledgeBaseDataSources } from '../../../operations/knowledge-base/hydrate-data-sources'; import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; import { type StackDiffSummary, @@ -103,6 +103,8 @@ interface DeployFlowState { numStacksWithChanges?: number; /** Notes to display after successful deploy (e.g., transaction search info) */ deployNotes: string[]; + /** Managed-memory heads-up, shown while the CFN apply runs (null when not applicable) */ + managedMemoryNotice: string | null; /** Warnings from post-deploy steps (config bundles, AB tests) */ postDeployWarnings: string[]; /** True if any post-deploy sub-resource operation had errors */ @@ -152,6 +154,28 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState }); const [publishAssetsStep, setPublishAssetsStep] = useState({ label: 'Publish assets', status: 'pending' }); const [deployStep, setDeployStep] = useState({ label: 'Deploy to AWS', status: 'pending' }); + const [persistStateStep, setPersistStateStep] = useState({ + label: 'Persist deployment state', + status: 'pending', + }); + // Whether the hydrate-KB step needs to run for this deploy. False (the + // common case) when every KB had its `dataSources[]` already populated by + // the per-DS CFN outputs the L3 emits since #234 — the persist step did + // the work and hydrate would be a pure no-op. We hide the step from the + // visible list in that case so the user doesn't see a phantom phase. Set + // by the deploy-time code right before the hydrate call (after the parse + // step exposes which KBs came back with empty dataSources[]). + const [needsKbHydration, setNeedsKbHydration] = useState(false); + const [hydrateKbStep, setHydrateKbStep] = useState({ + label: 'Hydrate knowledge base data sources', + status: 'pending', + }); + const [autoIngestStep, setAutoIngestStep] = useState({ + label: 'Auto-ingest knowledge bases', + status: 'pending', + }); + const [datasetSyncStep, setDatasetSyncStep] = useState({ label: 'Sync datasets', status: 'pending' }); + const [onlineEvalStep, setOnlineEvalStep] = useState({ label: 'Enable online evaluation', status: 'pending' }); const [diffStep, setDiffStep] = useState({ label: 'Run CDK diff', status: 'pending' }); const [diffSummaries, setDiffSummaries] = useState([]); const [numStacksWithChanges, setNumStacksWithChanges] = useState(); @@ -162,6 +186,9 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const isDiffRunningRef = useRef(false); const [deployOutput, setDeployOutput] = useState(null); const [deployMessages, setDeployMessages] = useState([]); + // Managed-memory heads-up: shown WHILE the slow CFN apply runs (not gated on success like + // deployNotes), because explaining the 3-5 min memory provisioning is the whole point. + const [managedMemoryNotice, setManagedMemoryNotice] = useState(null); const [stackOutputs, setStackOutputs] = useState>({}); const [targetStatuses, setTargetStatuses] = useState<{ name: string; status: string }[]>([]); const [shouldStartDeploy, setShouldStartDeploy] = useState(false); @@ -177,6 +204,14 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState setPreDeployDiffStep({ label: 'Computing diff changes...', status: 'pending' }); setPublishAssetsStep({ label: 'Publish assets', status: 'pending' }); setDeployStep({ label: 'Deploy to AWS', status: 'pending' }); + setPersistStateStep({ label: 'Persist deployment state', status: 'pending' }); + setHydrateKbStep({ label: 'Hydrate knowledge base data sources', status: 'pending' }); + setNeedsKbHydration(false); + setAutoIngestStep({ label: 'Auto-ingest knowledge bases', status: 'pending' }); + setDatasetSyncStep({ label: 'Sync datasets', status: 'pending' }); + setOnlineEvalStep({ label: 'Enable online evaluation', status: 'pending' }); + setPostDeployHasError(false); + setPostDeployWarnings([]); setDeployOutput(null); setHasTokenExpiredError(false); // Reset token expired state when retrying setHasStartedCfn(false); @@ -233,6 +268,9 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (!ctx || !currentStackName || !target) return; + setPersistStateStep(prev => ({ ...prev, status: 'running' })); + logger.startStep('Persist deployment state'); + const configIO = new ConfigIO(); const agentNames = ctx.projectSpec.runtimes?.map((a: { name: string }) => a.name) || []; @@ -324,6 +362,63 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const datasetNames = (ctx.projectSpec.datasets ?? []).map((d: { name: string }) => d.name); const datasets = parseDatasetOutputs(outputs, datasetNames); + // Parse config bundle outputs + const configBundleNames = (ctx.projectSpec.configBundles ?? []).map((b: { name: string }) => b.name); + const configBundles = parseConfigBundleOutputs(outputs, configBundleNames); + + // Parse runtime endpoint outputs + const endpointSpecs: { agentName: string; endpointName: string }[] = []; + for (const runtime of ctx.projectSpec.runtimes ?? []) { + if (runtime.endpoints) { + for (const endpointName of Object.keys(runtime.endpoints)) { + endpointSpecs.push({ agentName: runtime.name, endpointName }); + } + } + } + const runtimeEndpoints = parseRuntimeEndpointOutputs(outputs, endpointSpecs); + + // Parse knowledge base outputs (CFN emits id+arn; per-DS outputs hydrate dataSources via getAtt('DataSourceId')). + const knowledgeBaseSpecs = ctx.projectSpec.knowledgeBases ?? []; + const knowledgeBaseNames = knowledgeBaseSpecs.map(kb => kb.name); + const knowledgeBases = parseKnowledgeBaseOutputs(outputs, knowledgeBaseNames); + + if (knowledgeBaseNames.length > 0 && Object.keys(knowledgeBases).length !== knowledgeBaseNames.length) { + logger.log( + `Deployed-state missing outputs for ${ + knowledgeBaseNames.length - Object.keys(knowledgeBases).length + } knowledge base(s).`, + 'warn' + ); + } + + // Hydrate dataSources[] for any KB whose CFN per-DS outputs were absent + // (older L3, before #234). With the current L3 the persist step has + // already filled `dataSources[]` from per-DS outputs — the hydrate + // function would short-circuit on every KB and the step would render as a + // pointless "running → success" flash. Skip it (and hide it from the + // visible step list) when nothing actually needs hydrating. + const kbsNeedingHydration = Object.values(knowledgeBases).filter(kb => kb.dataSources.length === 0); + if (kbsNeedingHydration.length > 0) { + setNeedsKbHydration(true); + setHydrateKbStep(prev => ({ ...prev, status: 'running' })); + logger.startStep('Hydrate knowledge base data sources'); + try { + await hydrateKnowledgeBaseDataSources({ + knowledgeBases, + knowledgeBaseSpecs, + region: target.region, + }); + logger.endStep('success'); + setHydrateKbStep(prev => ({ ...prev, status: 'success' })); + } catch (err) { + const msg = getErrorMessage(err); + logger.log(`Failed to hydrate knowledge base data sources: ${msg}`, 'warn'); + // Hydration failure is non-fatal — KBs are still deployed. + logger.endStep('success'); + setHydrateKbStep(prev => ({ ...prev, status: 'warn', warn: msg })); + } + } + // Expose outputs to UI setStackOutputs(outputs); @@ -353,36 +448,13 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const existingState = await configIO.readDeployedState().catch(() => undefined); - // Post-CDK: deploy imperative resources (harness) — preview mode only - let deployedHarnesses: Record | undefined; - if (isPreviewEnabled()) { - const imperativeManager = createDeploymentManager(); - const imperativeDeployedState: DeployedState = existingState ?? { targets: {} }; - const imperativeContext = { - projectSpec: ctx.projectSpec, - target, - configIO, - deployedState: imperativeDeployedState, - cdkOutputs: outputs, - onProgress: (step: string, status: 'start' | 'done' | 'error') => { - logger.log(`${step}: ${status}`); - }, - }; - - if (imperativeManager.hasDeployersForPhase('post-cdk', imperativeContext)) { - logger.startStep('Deploy harnesses'); - const postCdkResult = await imperativeManager.runPhase('post-cdk', imperativeContext); - const harnessResult = postCdkResult.results.get('harness'); - if (harnessResult?.state) { - deployedHarnesses = harnessResult.state as Record; - } - if (!postCdkResult.success) { - logger.endStep('error', postCdkResult.error); - throw new Error(`Harness deployment failed: ${postCdkResult.error}`); - } - logger.endStep('success'); - } - } + // Parse harness outputs (harnesses are now part of the CloudFormation stack). + // Preview-gated to match the synth path: with preview off, bin/cdk.ts emits no harness + // resource/outputs, so skip parsing entirely (see toolkit-lib/wrapper.ts + bin/cdk.ts). + const harnessNames = isPreviewEnabled() + ? (ctx.projectSpec.harnesses ?? []).map((h: { name: string }) => h.name) + : []; + const deployedHarnesses = parseHarnessOutputs(outputs, harnessNames); let deployedState = buildDeployedState({ targetName: target.name, @@ -398,8 +470,12 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState policyEngines, policies, datasets, + configBundles, + runtimeEndpoints, + knowledgeBases, harnesses: deployedHarnesses, payments, + abTestNames: (ctx.projectSpec.abTests ?? []).map((t: { name: string }) => t.name), }); try { @@ -414,10 +490,81 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState await configIO.writeDeployedState(deployedState); + logger.endStep('success'); + setPersistStateStep(prev => ({ ...prev, status: 'success' })); + + // Post-deploy: auto-trigger ingestion for any KB whose data-source URIs + // changed since the last deploy (or has never been ingested before). + const knowledgeBaseSpecsForIngest = ctx.projectSpec.knowledgeBases ?? []; + if (knowledgeBaseSpecsForIngest.length > 0) { + setAutoIngestStep(prev => ({ ...prev, status: 'running' })); + logger.startStep('Auto-ingest knowledge bases'); + try { + const previousKnowledgeBases = existingState?.targets?.[target.name]?.resources?.knowledgeBases; + const ingestResult = await autoIngestKnowledgeBases({ + region: target.region, + knowledgeBases: knowledgeBaseSpecsForIngest, + deployedKnowledgeBases: deployedState.targets?.[target.name]?.resources?.knowledgeBases ?? {}, + previousKnowledgeBases, + targetName: target.name, + deployedState, + onProgress: msg => logger.log(msg), + }); + + // Persist new sourcesHash values for KBs whose ingestion fired. + const targetResources = deployedState.targets[target.name]?.resources; + if (targetResources?.knowledgeBases) { + for (const r of ingestResult.results) { + if (r.status === 'started' && r.newSourcesHash) { + const record = targetResources.knowledgeBases[r.knowledgeBaseName]; + if (record) record.sourcesHash = r.newSourcesHash; + } + } + await configIO.writeDeployedState(deployedState); + } + + // Log per-KB result so the user sees what happened. + for (const r of ingestResult.results) { + if (r.status === 'started') { + logger.log( + `Knowledge base "${r.knowledgeBaseName}": ingestion started for ${r.startedJobCount} data source(s)` + ); + } else if (r.status === 'skipped') { + logger.log(`Knowledge base "${r.knowledgeBaseName}": skipped (${r.reason})`); + } else { + logger.log(`Knowledge base "${r.knowledgeBaseName}": ${r.error}`, 'warn'); + setPostDeployWarnings(prev => [...prev, `Knowledge base "${r.knowledgeBaseName}": ${r.error}`]); + } + } + + logger.endStep(ingestResult.hasErrors ? 'error' : 'success'); + if (ingestResult.hasErrors) { + // Don't fail the deploy — KBs and DSes are valid CFN resources even if + // ingestion failed. The user retries via 'agentcore run ingest --name X'. + setPostDeployHasError(true); + setAutoIngestStep(prev => ({ + ...prev, + status: 'error', + error: 'One or more knowledge bases failed to ingest', + })); + } else { + setAutoIngestStep(prev => ({ ...prev, status: 'success' })); + } + } catch (err) { + const errMsg = getErrorMessage(err); + logger.endStep('error', errMsg); + setPostDeployHasError(true); + setPostDeployWarnings(prev => [...prev, `Knowledge base auto-ingest failed: ${errMsg}`]); + setAutoIngestStep(prev => ({ ...prev, status: 'error', error: errMsg })); + } + } + // Post-deploy: Sync dataset examples from local JSONL to service DRAFT. const datasetSpecs = ctx.projectSpec.datasets ?? []; const deployedDatasetsRecord = deployedState.targets?.[target.name]?.resources?.datasets ?? {}; if (datasetSpecs.length > 0 && Object.keys(deployedDatasetsRecord).length > 0) { + setDatasetSyncStep(prev => ({ ...prev, status: 'running' })); + logger.startStep('Sync datasets'); try { const datasetSyncResult = await syncDatasets({ region: target.region, @@ -443,6 +590,15 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } setPostDeployHasError(true); setPostDeployWarnings(prev => [...prev, ...errors.map(err => `Dataset "${err.datasetName}": ${err.error}`)]); + logger.endStep('error', 'One or more datasets failed to sync'); + setDatasetSyncStep(prev => ({ + ...prev, + status: 'error', + error: 'One or more datasets failed to sync', + })); + } else { + logger.endStep('success'); + setDatasetSyncStep(prev => ({ ...prev, status: 'success' })); } for (const r of datasetSyncResult.results) { @@ -455,6 +611,8 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState logger.log(`Dataset sync failed: ${message}`, 'warn'); setPostDeployHasError(true); setPostDeployWarnings(prev => [...prev, `Dataset sync failed: ${message}`]); + logger.endStep('error', message); + setDatasetSyncStep(prev => ({ ...prev, status: 'error', error: message })); } } @@ -466,6 +624,8 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const previouslyDeployedOnlineEvals = existingState?.targets?.[target.name]?.resources?.onlineEvalConfigs ?? {}; const newOnlineEvalFullSpecs = onlineEvalFullSpecs.filter(c => !previouslyDeployedOnlineEvals[c.name]); if (newOnlineEvalFullSpecs.length > 0 && Object.keys(deployedOnlineEvalConfigs).length > 0) { + setOnlineEvalStep(prev => ({ ...prev, status: 'running' })); + logger.startStep('Enable online evaluation'); try { const enableResult = await enableOnlineEvalConfigs({ region: target.region, @@ -483,184 +643,29 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState ...prev, ...errors.map(err => `Online eval "${err.configName}": ${err.error}`), ]); + logger.endStep('error', 'One or more online eval configs failed to enable'); + setOnlineEvalStep(prev => ({ + ...prev, + status: 'error', + error: 'One or more online eval configs failed to enable', + })); + } else { + logger.endStep('success'); + setOnlineEvalStep(prev => ({ ...prev, status: 'success' })); } } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); logger.log(`Online eval enable failed: ${message}`, 'warn'); setPostDeployHasError(true); setPostDeployWarnings(prev => [...prev, `Online eval enable failed: ${message}`]); + logger.endStep('error', message); + setOnlineEvalStep(prev => ({ ...prev, status: 'error', error: message })); } } - // Post-deploy: Create/update configuration bundles - const configBundleSpecs = ctx.projectSpec.configBundles ?? []; - if (configBundleSpecs.length > 0) { - try { - // Resolve component key placeholders (e.g., {{runtime:name}} → real ARN) - const resolvedProjectSpec = resolveConfigBundleComponentKeys(ctx.projectSpec, deployedState, target.name); - const existingConfigBundles = deployedState.targets?.[target.name]?.resources?.configBundles; - const configBundleResult = await setupConfigBundles({ - region: target.region, - projectSpec: resolvedProjectSpec, - existingBundles: existingConfigBundles, - }); - - // Merge config bundle state into deployed state - if (Object.keys(configBundleResult.configBundles).length > 0) { - const updatedState = await configIO.readDeployedState().catch(() => deployedState); - const targetResources = updatedState.targets[target.name]?.resources; - if (targetResources) { - targetResources.configBundles = configBundleResult.configBundles; - await configIO.writeDeployedState(updatedState); - } - } - - if (configBundleResult.hasErrors) { - const errors = configBundleResult.results.filter(r => r.status === 'error'); - for (const err of errors) { - logger.log(`Config bundle "${err.bundleName}" setup error: ${err.error}`, 'warn'); - } - setPostDeployHasError(true); - setPostDeployWarnings(prev => [ - ...prev, - ...errors.map(err => `Config bundle "${err.bundleName}": ${err.error}`), - ]); - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - logger.log(`Config bundle setup failed: ${message}`, 'warn'); - setPostDeployHasError(true); - setPostDeployWarnings(prev => [...prev, `Config bundle setup failed: ${message}`]); - } - } - - // Pre-gateway: Delete orphaned AB tests so their gateway rules are cleaned up - // before we attempt to delete orphaned HTTP gateways. - const existingABTests = deployedState.targets?.[target.name]?.resources?.abTests; - if (existingABTests && Object.keys(existingABTests).length > 0) { - try { - const deleteResult = await deleteOrphanedABTests({ - region: target.region, - projectSpec: ctx.projectSpec, - existingABTests, - }); - - if (deleteResult.hasErrors) { - const errors = deleteResult.results.filter(r => r.status === 'error'); - for (const err of errors) { - logger.log(`AB test delete "${err.testName}" error: ${err.error}`, 'warn'); - } - setPostDeployHasError(true); - setPostDeployWarnings(prev => [...prev, ...errors.map(err => `AB test "${err.testName}": ${err.error}`)]); - } - - // Surface warnings (e.g., "AB test was stopped before deletion") - for (const r of deleteResult.results) { - if (r.warning) { - logger.log(r.warning, 'warn'); - setPostDeployWarnings(prev => [...prev, r.warning!]); - } - } - - // Update deployed state to remove deleted AB tests - if (deleteResult.results.some(r => r.status === 'deleted')) { - const updatedState = await configIO.readDeployedState().catch(() => deployedState); - const targetResources = updatedState.targets[target.name]?.resources; - if (targetResources?.abTests) { - for (const r of deleteResult.results) { - if (r.status === 'deleted') delete targetResources.abTests[r.testName]; - } - await configIO.writeDeployedState(updatedState); - deployedState = updatedState; - } - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - logger.log(`AB test orphan cleanup failed: ${message}`, 'warn'); - setPostDeployHasError(true); - setPostDeployWarnings(prev => [...prev, `AB test orphan cleanup failed: ${message}`]); - } - } - - // Post-deploy: Create/update HTTP gateways - const httpGatewaySpecs = ctx.projectSpec.httpGateways ?? []; - const existingHttpGateways = deployedState.targets?.[target.name]?.resources?.httpGateways; - if (httpGatewaySpecs.length > 0 || Object.keys(existingHttpGateways ?? {}).length > 0) { - try { - const deployedResources = deployedState.targets?.[target.name]?.resources; - const httpGatewayResult = await setupHttpGateways({ - region: target.region, - projectName: ctx.projectSpec.name, - projectSpec: ctx.projectSpec, - existingHttpGateways, - deployedResources, - }); - - // Always merge HTTP gateway state (even if empty, to clear deleted gateways) - const updatedState = await configIO.readDeployedState().catch(() => deployedState); - const targetResources = updatedState.targets[target.name]?.resources; - if (targetResources) { - targetResources.httpGateways = httpGatewayResult.httpGateways; - await configIO.writeDeployedState(updatedState); - deployedState = updatedState; - } - - if (httpGatewayResult.hasErrors) { - const errors = httpGatewayResult.results.filter(r => r.status === 'error'); - for (const err of errors) { - logger.log(`HTTP gateway "${err.gatewayName}" setup error: ${err.error}`, 'warn'); - } - setPostDeployHasError(true); - setPostDeployWarnings(prev => [ - ...prev, - ...errors.map(err => `HTTP gateway "${err.gatewayName}": ${err.error}`), - ]); - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - logger.log(`HTTP gateway setup failed: ${message}`, 'warn'); - setPostDeployHasError(true); - setPostDeployWarnings(prev => [...prev, `HTTP gateway setup failed: ${message}`]); - } - } - - // Post-deploy: Create/update AB tests - const abTestSpecs = ctx.projectSpec.abTests ?? []; - if (abTestSpecs.length > 0) { - try { - const existingABTests = deployedState.targets?.[target.name]?.resources?.abTests; - const deployedResources = deployedState.targets?.[target.name]?.resources; - const abTestResult = await setupABTests({ - region: target.region, - projectSpec: ctx.projectSpec, - existingABTests, - deployedResources, - }); - - if (Object.keys(abTestResult.abTests).length > 0) { - const updatedState = await configIO.readDeployedState().catch(() => deployedState); - const targetResources = updatedState.targets[target.name]?.resources; - if (targetResources) { - targetResources.abTests = abTestResult.abTests; - await configIO.writeDeployedState(updatedState); - } - } - - if (abTestResult.hasErrors) { - const errors = abTestResult.results.filter(r => r.status === 'error'); - for (const err of errors) { - logger.log(`AB test "${err.testName}" setup error: ${err.error}`, 'warn'); - } - setPostDeployHasError(true); - setPostDeployWarnings(prev => [...prev, ...errors.map(err => `AB test "${err.testName}": ${err.error}`)]); - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - logger.log(`AB test setup failed: ${message}`, 'warn'); - setPostDeployHasError(true); - setPostDeployWarnings(prev => [...prev, `AB test setup failed: ${message}`]); - } - } + // Config bundles are now managed via CloudFormation; their state is parsed + // from stack outputs above (no post-deploy API step). AB tests are managed + // as fire-and-forget jobs (agentcore run ab-test), not via the deploy path. // Query gateway target sync statuses (non-blocking) const allStatuses: { name: string; status: string }[] = []; @@ -696,8 +701,11 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const attrs = context ? computeDeployAttrs(context.projectSpec, 'deploy') : { ...DEFAULT_DEPLOY_ATTRS }; const run = async (): Promise<{ success: true } | { success: false; error: Error }> => { - // Run diff before deploy to capture pre-deploy differences - if (!isDiffRunningRef.current) { + // Run diff before deploy to capture pre-deploy differences. + // Skip for brand new stacks: CDK changeset-based diff creates a temporary stack + // in REVIEW_IN_PROGRESS then deletes it without waiting, racing with the deploy + // that immediately follows. + if (!context?.isFirstDeploy && !isDiffRunningRef.current) { isDiffRunningRef.current = true; setIsDiffLoading(true); setPreDeployDiffStep(prev => ({ ...prev, status: 'running' })); @@ -723,6 +731,19 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState logger.endStep('success'); setPreDeployDiffStep(prev => ({ ...prev, status: 'success' })); } + } else if (context?.isFirstDeploy) { + setPreDeployDiffStep(prev => ({ ...prev, status: 'success', label: 'Skip diff (new stack)' })); + } + + // Managed-memory heads-up: surface BEFORE the slow CFN apply so the 3-5 min memory + // provisioning wait is explained while it happens. Mirrors the CLI command path; both + // read the same shared detection + notice text so the wording can't drift. + if (!context?.isTeardownDeploy) { + const noticeConfigIO = new ConfigIO(); + if (await hasManagedMemoryHarness(noticeConfigIO, context?.projectSpec.harnesses)) { + logger.log(MANAGED_MEMORY_DEPLOY_NOTICE); + setManagedMemoryNotice(MANAGED_MEMORY_DEPLOY_NOTICE); + } } setPublishAssetsStep(prev => ({ ...prev, status: 'running' })); @@ -762,39 +783,24 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // Output goes to stdout via the switchable ioHost await cdkToolkitWrapper.deploy(); - if (context?.isTeardownDeploy) { - // Teardown imperative resources (harnesses) before destroying the stack - if (isPreviewEnabled()) { - const teardownTarget = context.awsTargets[0]; - if (teardownTarget) { - const imperativeManager = createDeploymentManager(); - const teardownConfigIO = new ConfigIO(); - const existingTeardownState = await teardownConfigIO - .readDeployedState() - .catch(() => ({ targets: {} }) as DeployedState); - const teardownContext = { - projectSpec: context.projectSpec, - target: teardownTarget, - configIO: teardownConfigIO, - deployedState: existingTeardownState, - onProgress: (step: string, status: 'start' | 'done' | 'error') => { - logger.log(`${step}: ${status}`); - }, - }; - - if (imperativeManager.hasDeployersForPhase('post-cdk', teardownContext)) { - logger.startStep('Tear down imperative resources'); - const teardownResult = await imperativeManager.teardownAll(teardownContext); - if (!teardownResult.success) { - logger.endStep('error', teardownResult.error); - throw new Error(`Imperative teardown failed: ${teardownResult.error}`); - } - logger.endStep('success'); - } - } - } + // CDK deploy itself is done. Mark "Deploy to AWS" success and let post-deploy + // phases (persist, hydrate KBs, auto-ingest, dataset sync, online evals, + // config bundles, HTTP gateways, AB tests) advance their own visible steps. + // + // No-change deploys never receive a progress-bearing CloudFormation event, so + // the message handler above never flips Publish assets out of 'running'. Catch + // both 'pending' and 'running' here so the step never gets stranded — without + // this the UI shows "stuck on Publish assets" during a 2m+ post-deploy ingest + // even though the underlying deploy had completed seconds in. + logger.endStep('success'); + setPublishAssetsStep(prev => + prev.status === 'success' || prev.status === 'error' ? prev : { ...prev, status: 'success' } + ); + setDeployStep(prev => ({ ...prev, status: 'success' })); + if (context?.isTeardownDeploy) { // After deploying the empty spec, destroy the stack entirely. + // Harnesses are part of the CloudFormation stack, so stack destroy handles them. // Clean up imperative payment credential providers before stack teardown. const targetName = context.awsTargets[0]?.name; if (targetName) { @@ -822,6 +828,15 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; logger.log(`Failed to persist deployed state: ${message}`, 'warn'); + // Mark whichever post-deploy step was running as errored so the visible + // step list resolves (areStepsComplete requires every step terminal). + // Only the persist step is reachable here without local handling. + setPersistStateStep(prev => + prev.status === 'running' ? { ...prev, status: 'error', error: message } : prev + ); + setHydrateKbStep(prev => (prev.status === 'running' ? { ...prev, status: 'error', error: message } : prev)); + setPostDeployHasError(true); + setPostDeployWarnings(p => [...p, `Persist deployed state failed: ${message}`]); } // Post-deploy: Enable CloudWatch Transaction Search (non-blocking, silent) @@ -857,12 +872,11 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } } + // Close any still-open logger step (defensive — post-deploy phases manage + // their own start/end pairs, so this usually no-ops). logger.endStep('success'); logger.finalize(true); setDeployOutput(`Deployed ${stackNames.length} stack(s): ${stackNames.join(', ')}`); - // Mark both steps as success (in case CFn events were never received) - setPublishAssetsStep(prev => ({ ...prev, status: 'success' })); - setDeployStep(prev => ({ ...prev, status: 'success' })); return { success: true } as const; } catch (err) { const errorMsg = getErrorMessage(err); @@ -1015,60 +1029,110 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } }, [preflight.phase, preflight.cdkToolkitWrapper, logger, skipPreflight]); + // Project-content-driven inclusion: only show post-deploy steps that will actually run. + const projectSpec = context?.projectSpec; + const hasKnowledgeBases = (projectSpec?.knowledgeBases?.length ?? 0) > 0; + const hasDatasets = (projectSpec?.datasets?.length ?? 0) > 0; + const hasOnlineEvalConfigs = (projectSpec?.onlineEvalConfigs?.length ?? 0) > 0; + const steps = useMemo(() => { if (diffMode) { return skipPreflight ? [diffStep] : [...preflight.steps, diffStep]; } - return skipPreflight - ? [preDeployDiffStep, publishAssetsStep, deployStep] - : [...preflight.steps, preDeployDiffStep, publishAssetsStep, deployStep]; - }, [preflight.steps, preDeployDiffStep, publishAssetsStep, deployStep, diffStep, skipPreflight, diffMode]); + const preflightSteps = skipPreflight ? [] : preflight.steps; + const isTeardown = projectSpec ? !!context?.isTeardownDeploy : false; + + const postDeploySteps: Step[] = isTeardown + ? [] + : [ + persistStateStep, + ...(hasKnowledgeBases && needsKbHydration ? [hydrateKbStep] : []), + ...(hasKnowledgeBases ? [autoIngestStep] : []), + ...(hasDatasets ? [datasetSyncStep] : []), + ...(hasOnlineEvalConfigs ? [onlineEvalStep] : []), + ]; + + return [...preflightSteps, preDeployDiffStep, publishAssetsStep, deployStep, ...postDeploySteps]; + }, [ + preflight.steps, + preDeployDiffStep, + publishAssetsStep, + deployStep, + persistStateStep, + hydrateKbStep, + autoIngestStep, + datasetSyncStep, + onlineEvalStep, + diffStep, + skipPreflight, + diffMode, + hasKnowledgeBases, + needsKbHydration, + hasDatasets, + hasOnlineEvalConfigs, + context?.isTeardownDeploy, + projectSpec, + ]); + + const hasError = hasStepError(steps); + const isComplete = areStepsComplete(steps); const phase: DeployPhase = useMemo(() => { - const activeStep = diffMode ? diffStep : deployStep; + if (diffMode) { + const activeStep = diffStep; + if (skipPreflight) { + if (!shouldStartDeploy && activeStep.status === 'pending') { + return 'idle'; + } + if (activeStep.status === 'error') { + return 'error'; + } + if (activeStep.status === 'success') { + return 'complete'; + } + return 'deploying'; + } + + if (preflight.phase === 'idle') return 'idle'; + if (preflight.phase === 'error') return 'error'; + if (preflight.phase === 'teardown-confirm') return 'teardown-confirm'; + if (preflight.phase === 'credentials-prompt') return 'credentials-prompt'; + if (preflight.phase === 'bootstrap-confirm') return 'bootstrap-confirm'; + if ( + preflight.phase === 'running' || + preflight.phase === 'bootstrapping' || + preflight.phase === 'identity-setup' + ) { + return 'running'; + } + if (activeStep.status === 'error') return 'error'; + if (activeStep.status === 'success') return 'complete'; + return 'deploying'; + } + // Deploy mode: derive from the full visible step list so post-CDK phases can + // hold the flow in 'deploying' until they all settle. if (skipPreflight) { - if (!shouldStartDeploy && activeStep.status === 'pending') { + if (!shouldStartDeploy && deployStep.status === 'pending') { return 'idle'; } - if (activeStep.status === 'error') { - return 'error'; - } - if (activeStep.status === 'success') { - return 'complete'; - } + if (hasError) return 'error'; + if (isComplete) return 'complete'; return 'deploying'; } - if (preflight.phase === 'idle') { - return 'idle'; - } - if (preflight.phase === 'error') { - return 'error'; - } - if (preflight.phase === 'teardown-confirm') { - return 'teardown-confirm'; - } - if (preflight.phase === 'credentials-prompt') { - return 'credentials-prompt'; - } - if (preflight.phase === 'bootstrap-confirm') { - return 'bootstrap-confirm'; - } + if (preflight.phase === 'idle') return 'idle'; + if (preflight.phase === 'error') return 'error'; + if (preflight.phase === 'teardown-confirm') return 'teardown-confirm'; + if (preflight.phase === 'credentials-prompt') return 'credentials-prompt'; + if (preflight.phase === 'bootstrap-confirm') return 'bootstrap-confirm'; if (preflight.phase === 'running' || preflight.phase === 'bootstrapping' || preflight.phase === 'identity-setup') { return 'running'; } - if (activeStep.status === 'error') { - return 'error'; - } - if (activeStep.status === 'success') { - return 'complete'; - } + if (hasError) return 'error'; + if (isComplete) return 'complete'; return 'deploying'; - }, [preflight.phase, deployStep, diffStep, skipPreflight, shouldStartDeploy, diffMode]); - - const hasError = hasStepError(steps); - const isComplete = areStepsComplete(steps); + }, [preflight.phase, deployStep, diffStep, skipPreflight, shouldStartDeploy, diffMode, hasError, isComplete]); // Combine token expired errors from both preflight and deploy phases const combinedTokenExpiredError = hasTokenExpiredError || preflight.hasTokenExpiredError; @@ -1091,6 +1155,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState diffSummaries, numStacksWithChanges, deployNotes, + managedMemoryNotice, postDeployWarnings, postDeployHasError, isDiffLoading, diff --git a/src/cli/tui/screens/eval/EvalHubScreen.tsx b/src/cli/tui/screens/eval/EvalHubScreen.tsx index 27cb2e66f..f1a41b519 100644 --- a/src/cli/tui/screens/eval/EvalHubScreen.tsx +++ b/src/cli/tui/screens/eval/EvalHubScreen.tsx @@ -4,7 +4,14 @@ import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import React, { useMemo } from 'react'; -type EvalHubView = 'run-eval' | 'runs' | 'run-batch-eval' | 'batch-eval-history' | 'online-dashboard'; +type EvalHubView = + | 'run-eval' + | 'runs' + | 'run-batch-eval' + | 'batch-eval-history' + | 'run-insights' + | 'insights-jobs' + | 'online-dashboard'; interface EvalHubScreenProps { onSelect: (view: EvalHubView) => void; @@ -27,8 +34,18 @@ export function EvalHubScreen({ onSelect, onExit }: EvalHubScreenProps) { }, { id: 'batch-eval-history', - title: 'Batch Eval History', - description: 'View past batch evaluation results (local)', + title: 'Batch Eval Jobs', + description: 'View batch evaluation jobs and their results', + }, + { + id: 'run-insights', + title: 'Run Insights [preview]', + description: 'Run failure analysis on agent sessions', + }, + { + id: 'insights-jobs', + title: 'Insights Jobs [preview]', + description: 'View past insights analysis jobs', }, { id: 'online-dashboard', diff --git a/src/cli/tui/screens/export/ExportHarnessFlow.tsx b/src/cli/tui/screens/export/ExportHarnessFlow.tsx new file mode 100644 index 000000000..75d29ab53 --- /dev/null +++ b/src/cli/tui/screens/export/ExportHarnessFlow.tsx @@ -0,0 +1,196 @@ +import { ErrorPrompt, GradientText, NextSteps, Screen, StepProgress } from '../../components'; +import type { NextStep, Step } from '../../components'; +import { ExportHarnessScreen } from './ExportHarnessScreen'; +import type { ExportHarnessConfig } from './types'; +import { Box, Text } from 'ink'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'loading' } + | { name: 'wizard'; harnessNames: string[]; existingAgentNames: string[]; containerOnlyHarnesses: Set } + | { name: 'no-harnesses' } + | { name: 'exporting'; steps: Step[] } + | { name: 'success'; agentName: string; notesPath: string } + | { name: 'error'; message: string }; + +interface ExportHarnessFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDeploy?: () => void; +} + +const EXPORT_SUCCESS_STEPS: NextStep[] = [{ command: 'deploy', label: 'Deploy to AWS' }]; + +export function ExportHarnessFlow({ isInteractive = true, onExit, onBack, onDeploy }: ExportHarnessFlowProps) { + const [flow, setFlow] = useState({ name: 'loading' }); + + useEffect(() => { + void (async () => { + try { + const { ConfigIO } = await import('../../../../lib'); + const configIO = new ConfigIO(); + if (!configIO.hasProject()) { + setFlow({ name: 'no-harnesses' }); + return; + } + const project = await configIO.readProjectSpec(); + const harnessNames = (project.harnesses ?? []).map((h: { name: string }) => h.name); + if (harnessNames.length === 0) { + setFlow({ name: 'no-harnesses' }); + return; + } + const existingAgentNames = project.runtimes.map((r: { name: string }) => r.name); + const containerOnlyHarnesses = new Set(); + await Promise.all( + harnessNames.map(async (name: string) => { + try { + const spec = await configIO.readHarnessSpec(name); + if (spec.containerUri || spec.dockerfile) containerOnlyHarnesses.add(name); + } catch { + // unreadable spec — leave unrestricted, mapper will error on export + } + }) + ); + setFlow({ name: 'wizard', harnessNames, existingAgentNames, containerOnlyHarnesses }); + } catch (err) { + const { getErrorMessage } = await import('../../../errors'); + setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + }, []); + + useEffect(() => { + if (!isInteractive && flow.name === 'success') { + onExit(); + } + }, [isInteractive, flow, onExit]); + + const handleComplete = useCallback(async (config: ExportHarnessConfig) => { + const progressSteps: Step[] = [ + { label: 'Reading harness configuration', status: 'running' }, + { label: 'Mapping to Strands template config', status: 'pending' }, + { label: 'Rendering agent code', status: 'pending' }, + ...(config.build === 'Container' + ? [{ label: 'Generating uv.lock for container build', status: 'pending' as const }] + : []), + { label: 'Updating agentcore.json', status: 'pending' }, + { label: 'Writing EXPORT_NOTES.md', status: 'pending' }, + ]; + setFlow({ name: 'exporting', steps: progressSteps }); + + let stepIdx = 0; + const advanceStep = (_message: string) => { + const currentStep = progressSteps[stepIdx]; + if (currentStep) { + progressSteps[stepIdx] = { ...currentStep, status: 'success' }; + } + stepIdx++; + const nextStep = progressSteps[stepIdx]; + if (nextStep) { + progressSteps[stepIdx] = { ...nextStep, status: 'running' }; + } + setFlow({ name: 'exporting', steps: [...progressSteps] }); + }; + + try { + const { handleExportHarness } = await import('../../../commands/export/harness-action'); + const result = await handleExportHarness( + { name: config.harness, targetAgentName: config.targetAgentName, build: config.build }, + { onProgress: advanceStep } + ); + + // Mark last running step as success + const lastStep = progressSteps[stepIdx]; + if (lastStep) { + progressSteps[stepIdx] = { ...lastStep, label: lastStep.label, status: 'success' }; + setFlow({ name: 'exporting', steps: [...progressSteps] }); + } + + if (!result.success) { + setFlow({ name: 'error', message: result.error.message }); + return; + } + + setFlow({ name: 'success', agentName: result.agentName, notesPath: result.notesPath }); + } catch (err) { + const { getErrorMessage } = await import('../../../errors'); + setFlow({ name: 'error', message: getErrorMessage(err) }); + } + }, []); + + if (flow.name === 'loading') { + return ( + + + + ); + } + + if (flow.name === 'no-harnesses') { + return ( + + ); + } + + if (flow.name === 'wizard') { + return ( + void handleComplete(config)} + onExit={onBack} + /> + ); + } + + if (flow.name === 'exporting') { + return ( + { + /* noop while exporting */ + }} + > + + + ); + } + + if (flow.name === 'success') { + const handleSelect = (step: NextStep) => { + if (step.command === 'deploy') { + onDeploy?.(); + } else { + onExit(); + } + }; + + return ( + + + + ✓ Exported harness → runtime agent {flow.agentName} + Generated: app/{flow.agentName}/ · agentcore/agentcore.json updated + Review export notes: {flow.notesPath} + + {isInteractive && ( + + )} + + + ); + } + + if (flow.name === 'error') { + return ; + } + + return null; +} diff --git a/src/cli/tui/screens/export/ExportHarnessScreen.tsx b/src/cli/tui/screens/export/ExportHarnessScreen.tsx new file mode 100644 index 000000000..c1c94efc4 --- /dev/null +++ b/src/cli/tui/screens/export/ExportHarnessScreen.tsx @@ -0,0 +1,137 @@ +import { AgentNameSchema } from '../../../../schema'; +import { ConfirmReview, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; +import type { SelectableItem } from '../../components'; +import { useListNavigation } from '../../hooks'; +import type { ExportHarnessConfig } from './types'; +import { EXPORT_HARNESS_STEP_LABELS } from './types'; +import { useExportHarnessWizard } from './useExportHarnessWizard'; +import React from 'react'; + +interface ExportHarnessScreenProps { + harnessNames: string[]; + existingAgentNames: string[]; + containerOnlyHarnesses: Set; + onComplete: (config: ExportHarnessConfig) => void; + onExit: () => void; +} + +const BUILD_ITEMS: SelectableItem[] = [ + { id: 'CodeZip', title: 'CodeZip', description: 'Package Python source as a zip artifact (default)' }, + { id: 'Container', title: 'Container', description: 'Build a Docker container image via ECR and CodeBuild' }, +]; + +const CONFIRM_ITEM = [{ id: 'confirm', title: 'Confirm' }]; + +export function ExportHarnessScreen({ + harnessNames, + existingAgentNames, + containerOnlyHarnesses, + onComplete, + onExit, +}: ExportHarnessScreenProps) { + const wizard = useExportHarnessWizard(harnessNames, onExit); + const { config, step, steps, goBack, setHarness, setTargetAgentName, setBuild } = wizard; + + const availableBuildItems = containerOnlyHarnesses.has(config.harness) + ? BUILD_ITEMS.filter(b => b.id === 'Container') + : BUILD_ITEMS; + + const harnessItems: SelectableItem[] = harnessNames.map(n => ({ id: n, title: n })); + + const harnessNav = useListNavigation({ + items: harnessItems, + onSelect: item => setHarness(item.id), + onExit: onExit, + isActive: step === 'select-harness', + }); + + const buildNav = useListNavigation({ + items: availableBuildItems, + onSelect: item => setBuild(item.id as 'CodeZip' | 'Container'), + onExit: goBack, + isActive: step === 'build-type', + }); + + useListNavigation({ + items: CONFIRM_ITEM, + onSelect: () => onComplete(config), + onExit: goBack, + isActive: step === 'confirm', + }); + + if (step === 'select-harness') { + return ( + + + + + ); + } + + if (step === 'target-name') { + return ( + + + { + setTargetAgentName(value.trim()); + }} + onCancel={goBack} + customValidation={value => { + const trimmed = value.trim(); + if (!trimmed) return 'Name is required'; + const parsed = AgentNameSchema.safeParse(trimmed); + if (!parsed.success) return parsed.error.issues[0]?.message ?? 'Invalid name'; + if (existingAgentNames.includes(trimmed)) return `Agent "${trimmed}" already exists`; + return true; + }} + /> + + ); + } + + if (step === 'build-type') { + return ( + + + + + ); + } + + if (step === 'confirm') { + return ( + + + + + ); + } + + return null; +} diff --git a/src/cli/tui/screens/export/index.ts b/src/cli/tui/screens/export/index.ts new file mode 100644 index 000000000..569002e34 --- /dev/null +++ b/src/cli/tui/screens/export/index.ts @@ -0,0 +1 @@ +export { ExportHarnessFlow } from './ExportHarnessFlow'; diff --git a/src/cli/tui/screens/export/types.ts b/src/cli/tui/screens/export/types.ts new file mode 100644 index 000000000..a1dbfa4c6 --- /dev/null +++ b/src/cli/tui/screens/export/types.ts @@ -0,0 +1,14 @@ +export type ExportHarnessStep = 'select-harness' | 'target-name' | 'build-type' | 'confirm'; + +export interface ExportHarnessConfig { + harness: string; + targetAgentName: string; + build: 'CodeZip' | 'Container'; +} + +export const EXPORT_HARNESS_STEP_LABELS: Record = { + 'select-harness': 'Select harness', + 'target-name': 'Agent name', + 'build-type': 'Build type', + confirm: 'Confirm', +}; diff --git a/src/cli/tui/screens/export/useExportHarnessWizard.ts b/src/cli/tui/screens/export/useExportHarnessWizard.ts new file mode 100644 index 000000000..0049cff14 --- /dev/null +++ b/src/cli/tui/screens/export/useExportHarnessWizard.ts @@ -0,0 +1,63 @@ +import type { ExportHarnessConfig, ExportHarnessStep } from './types'; +import { useCallback, useState } from 'react'; + +function defaultTargetName(harness: string): string { + return `${harness}Agent`; +} + +export function useExportHarnessWizard(harnessNames: string[], onExit: () => void) { + const initialHarness = harnessNames[0] ?? ''; + const [step, setStep] = useState(harnessNames.length <= 1 ? 'target-name' : 'select-harness'); + const [config, setConfig] = useState({ + harness: initialHarness, + targetAgentName: defaultTargetName(initialHarness), + build: 'CodeZip', + }); + + const steps: ExportHarnessStep[] = + harnessNames.length <= 1 + ? ['target-name', 'build-type', 'confirm'] + : ['select-harness', 'target-name', 'build-type', 'confirm']; + + const currentIndex = steps.indexOf(step); + + const goBack = useCallback(() => { + const idx = steps.indexOf(step); + if (idx === 0) { + onExit(); + return; + } + const prev = steps[idx - 1]; + if (prev) setStep(prev); + }, [step, steps, onExit]); + + const setHarness = useCallback((harness: string) => { + setConfig(c => ({ + ...c, + harness, + targetAgentName: defaultTargetName(harness), + })); + setStep('target-name'); + }, []); + + const setTargetAgentName = useCallback((targetAgentName: string) => { + setConfig(c => ({ ...c, targetAgentName })); + setStep('build-type'); + }, []); + + const setBuild = useCallback((build: 'CodeZip' | 'Container') => { + setConfig(c => ({ ...c, build })); + setStep('confirm'); + }, []); + + return { + config, + step, + steps, + currentIndex, + goBack, + setHarness, + setTargetAgentName, + setBuild, + }; +} diff --git a/src/cli/tui/screens/generate/__tests__/types.test.ts b/src/cli/tui/screens/generate/__tests__/types.test.ts new file mode 100644 index 000000000..42ba919b3 --- /dev/null +++ b/src/cli/tui/screens/generate/__tests__/types.test.ts @@ -0,0 +1,45 @@ +import { getProtocolOptionsForLanguage, getSDKOptionsForProtocol } from '../types.js'; +import { describe, expect, it } from 'vitest'; + +describe('getSDKOptionsForProtocol', () => { + it('excludes Vercel AI for Python HTTP agents (Vercel is TypeScript-only)', () => { + const ids = getSDKOptionsForProtocol('HTTP', 'Python').map(o => o.id); + expect(ids).toContain('Strands'); + expect(ids).toContain('LangChain_LangGraph'); + expect(ids).not.toContain('VercelAI'); + }); + + it('includes Vercel AI for TypeScript HTTP agents', () => { + const ids = getSDKOptionsForProtocol('HTTP', 'TypeScript').map(o => o.id); + expect(ids).toContain('Strands'); + expect(ids).toContain('VercelAI'); + }); + + it('restricts TypeScript to Strands and Vercel AI only', () => { + const ids = getSDKOptionsForProtocol('HTTP', 'TypeScript').map(o => o.id); + expect(ids).not.toContain('LangChain_LangGraph'); + expect(ids).not.toContain('GoogleADK'); + expect(ids).not.toContain('OpenAIAgents'); + }); + + it('intersects protocol and language support (A2A + Python excludes OpenAIAgents and Vercel)', () => { + const ids = getSDKOptionsForProtocol('A2A', 'Python').map(o => o.id); + expect(ids).toContain('Strands'); + expect(ids).not.toContain('OpenAIAgents'); + expect(ids).not.toContain('VercelAI'); + }); +}); + +describe('getProtocolOptionsForLanguage', () => { + it('restricts TypeScript to HTTP only', () => { + const ids = getProtocolOptionsForLanguage('TypeScript').map(o => o.id); + expect(ids).toEqual(['HTTP']); + }); + + it('offers all protocols for Python', () => { + const ids = getProtocolOptionsForLanguage('Python').map(o => o.id); + expect(ids).toContain('HTTP'); + expect(ids).toContain('MCP'); + expect(ids).toContain('A2A'); + }); +}); diff --git a/src/cli/tui/screens/generate/types.ts b/src/cli/tui/screens/generate/types.ts index 41a833f30..30bbaf066 100644 --- a/src/cli/tui/screens/generate/types.ts +++ b/src/cli/tui/screens/generate/types.ts @@ -9,7 +9,12 @@ import type { SDKFramework, TargetLanguage, } from '../../../../schema'; -import { DEFAULT_MODEL_IDS, PROTOCOL_FRAMEWORK_MATRIX, getSupportedModelProviders } from '../../../../schema'; +import { + DEFAULT_MODEL_IDS, + PROTOCOL_FRAMEWORK_MATRIX, + getFrameworksForLanguage, + getSupportedModelProviders, +} from '../../../../schema'; import type { JwtConfigOptions } from '../../../primitives/auth-utils'; export type GenerateStep = @@ -160,13 +165,15 @@ export const SDK_OPTIONS = [ /** * Get SDK options filtered by protocol compatibility and target language. - * TypeScript currently only supports Strands. + * Frameworks must ship a template for the chosen language — e.g. Vercel AI is + * TypeScript-only, so it never appears for Python agents. */ export function getSDKOptionsForProtocol(protocol: ProtocolMode, language?: TargetLanguage) { const supportedFrameworks = PROTOCOL_FRAMEWORK_MATRIX[protocol]; const byProtocol = SDK_OPTIONS.filter(option => supportedFrameworks.includes(option.id)); - if (language === 'TypeScript') { - return byProtocol.filter(option => option.id === 'Strands' || option.id === 'VercelAI'); + if (language === 'Python' || language === 'TypeScript') { + const byLanguage = getFrameworksForLanguage(language); + return byProtocol.filter(option => byLanguage.includes(option.id)); } return byProtocol; } @@ -213,7 +220,7 @@ export const ADVANCED_SETTING_OPTIONS = [ { id: 'filesystem', title: 'Filesystem mounts', description: 'Session storage, EFS, and S3 Files mounts' }, { id: 'configBundle', - title: 'Config bundle [preview]', + title: 'Config bundle', description: 'Manage system prompt and tool config without redeploying', }, ] as const; diff --git a/src/cli/tui/screens/generate/useGenerateWizard.ts b/src/cli/tui/screens/generate/useGenerateWizard.ts index 36fe7ef7d..e4267fc7c 100644 --- a/src/cli/tui/screens/generate/useGenerateWizard.ts +++ b/src/cli/tui/screens/generate/useGenerateWizard.ts @@ -285,7 +285,10 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { resetFilesystemState(); } // Config bundle has no sub-steps — set flag immediately - setConfig(c => ({ ...c, withConfigBundle: selected.has('configBundle') || undefined })); + setConfig(c => ({ + ...c, + withConfigBundle: selected.has('configBundle') || undefined, + })); // Navigate to first advanced sub-step — determined by the steps memo on next render. // Use setTimeout so the steps memo recalculates with the new advancedSettings first. setTimeout(() => { diff --git a/src/cli/tui/screens/harness/AddHarnessFlow.tsx b/src/cli/tui/screens/harness/AddHarnessFlow.tsx index 0e24b6466..bede73f7a 100644 --- a/src/cli/tui/screens/harness/AddHarnessFlow.tsx +++ b/src/cli/tui/screens/harness/AddHarnessFlow.tsx @@ -1,12 +1,15 @@ +import { MANAGED_MEMORY_ADD_NOTICE } from '../../../operations/deploy'; import { ErrorPrompt } from '../../components'; import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { useExistingCredentials } from '../identity/useCreateIdentity'; import { AddHarnessScreen } from './AddHarnessScreen'; import type { AddHarnessConfig } from './types'; -import React, { useCallback, useEffect, useState } from 'react'; +import { Box, Text } from 'ink'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; type FlowState = | { name: 'create-wizard' } - | { name: 'create-success'; harnessName: string; loading?: boolean; loadingMessage?: string } + | { name: 'create-success'; harnessName: string; managedMemory?: boolean; loading?: boolean; loadingMessage?: string } | { name: 'error'; message: string }; interface AddHarnessFlowProps { @@ -20,6 +23,12 @@ interface AddHarnessFlowProps { export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, onDeploy }: AddHarnessFlowProps) { const [flow, setFlow] = useState({ name: 'create-wizard' }); const [existingNames, setExistingNames] = useState([]); + const { credentials } = useExistingCredentials(); + + const apiKeyCredentialNames = useMemo( + () => credentials.filter(c => c.authorizerType === 'ApiKeyCredentialProvider').map(c => c.name), + [credentials] + ); useEffect(() => { void (async () => { @@ -52,12 +61,46 @@ export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, on modelId: config.modelId, apiFormat: config.apiFormat, apiKeyArn: config.apiKeyArn, - skipMemory: config.skipMemory, + apiBase: config.apiBase, + additionalParams: config.additionalParams, + // Memory: when the mode-tagged union is present (gated ON), translate it to the primitive's + // memory-mode options; otherwise fall back to the legacy skipMemory + flat tuning fields. + ...(config.memory + ? config.memory.mode === 'managed' + ? { + memoryMode: 'managed' as const, + memoryStrategies: config.memory.strategies, + memoryEventExpiryDays: config.memory.eventExpiryDuration, + memoryEncryptionKeyArn: config.memory.encryptionKeyArn, + } + : config.memory.mode === 'existing' + ? { + memoryMode: 'existing' as const, + memoryName: config.memory.name, + memoryArn: config.memory.arn, + memoryActorId: config.memory.actorId, + messagesCount: config.memory.messagesCount, + memoryTopK: config.memory.topK, + memoryRelevanceScore: config.memory.relevanceScore, + } + : { memoryMode: 'disabled' as const, skipMemory: true } + : { + skipMemory: config.skipMemory, + messagesCount: config.messagesCount, + memoryTopK: config.memoryTopK, + memoryRelevanceScore: config.memoryRelevanceScore, + }), containerUri: config.containerUri, dockerfilePath: config.dockerfilePath, maxIterations: config.maxIterations, maxTokens: config.maxTokens, timeoutSeconds: config.timeoutSeconds, + temperature: config.temperature, + topP: config.topP, + topK: config.topK, + modelMaxTokens: config.modelMaxTokens, + allowedTools: config.allowedTools, + mcpHeaders: config.mcpHeaders, truncationStrategy: config.truncationStrategy, networkMode: config.networkMode, subnets: config.subnets, @@ -79,6 +122,7 @@ export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, on .map(s => s.trim()) .filter(Boolean) : undefined, + skills: config.skills, authorizerType: config.authorizerType, jwtConfig: config.jwtConfig ? { @@ -89,6 +133,8 @@ export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, on customClaims: config.jwtConfig.customClaims, clientId: config.jwtConfig.clientId, clientSecret: config.jwtConfig.clientSecret, + privateEndpoint: config.jwtConfig.privateEndpoint, + privateEndpointOverrides: config.jwtConfig.privateEndpointOverrides, } : undefined, }); @@ -97,7 +143,7 @@ export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, on return; } - setFlow({ name: 'create-success', harnessName: config.name }); + setFlow({ name: 'create-success', harnessName: config.name, managedMemory: result.memoryMode === 'managed' }); } catch (err) { const { getErrorMessage } = await import('../../../errors'); setFlow({ name: 'error', message: getErrorMessage(err) }); @@ -108,6 +154,7 @@ export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, on return ( void handleCreateComplete(config)} onExit={onBack} /> @@ -120,6 +167,13 @@ export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, on isInteractive={isInteractive} message={`Added harness: ${flow.harnessName}`} detail="Harness config written to app/. Deploy with `agentcore deploy`." + summary={ + flow.managedMemory ? ( + + Note: {MANAGED_MEMORY_ADD_NOTICE} + + ) : undefined + } loading={flow.loading} loadingMessage={flow.loadingMessage} onAddAnother={onBack} diff --git a/src/cli/tui/screens/harness/AddHarnessScreen.tsx b/src/cli/tui/screens/harness/AddHarnessScreen.tsx index c2ba67d72..3d94b85cb 100644 --- a/src/cli/tui/screens/harness/AddHarnessScreen.tsx +++ b/src/cli/tui/screens/harness/AddHarnessScreen.tsx @@ -1,5 +1,12 @@ import type { HarnessModelProvider, RuntimeAuthorizerType } from '../../../../schema'; -import { HarnessApiFormatSchema, MAX_EFS_MOUNTS, MAX_S3_MOUNTS, NetworkModeSchema } from '../../../../schema'; +import { + HarnessApiFormatSchema, + MAX_EFS_MOUNTS, + MAX_S3_MOUNTS, + NetworkModeSchema, + SECURITY_GROUP_ID_PATTERN, + SUBNET_ID_PATTERN, +} from '../../../../schema'; import { HarnessNameSchema, HarnessTruncationStrategySchema } from '../../../../schema/schemas/primitives/harness'; import { ARN_VALIDATION_MESSAGE, isValidArn } from '../../../commands/shared/arn-utils'; import { @@ -32,29 +39,52 @@ import { CONTAINER_MODE_OPTIONS, GATEWAY_OUTBOUND_AUTH_OPTIONS, HARNESS_STEP_LABELS, + MANAGED_STRATEGY_OPTIONS, + MEMORY_MODE_OPTIONS, MEMORY_OPTIONS, MODEL_PROVIDER_OPTIONS, NETWORK_MODE_OPTIONS, OPENAI_API_FORMAT_OPTIONS, + SKILL_SOURCE_TYPE_OPTIONS, TOOL_SELECT_OPTIONS, TRUNCATION_STRATEGY_OPTIONS, } from './types'; import { useAddHarnessWizard } from './useAddHarnessWizard'; +import { isGatedFeaturesEnabled } from '@/cli/feature-flags'; import { Text } from 'ink'; -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; + +/** Inline-validate a comma-separated VPC id list against `pattern` so malformed ids are rejected + * at the step (not deferred to a late write/deploy error with a misleading green checkmark). */ +function validateIdList(value: string, pattern: RegExp, label: string, example: string): true | string { + const ids = value + .split(',') + .map(s => s.trim()) + .filter(Boolean); + if (ids.length === 0) return `At least one ${label} is required for VPC mode`; + const invalid = ids.find(id => !pattern.test(id)); + return invalid ? `Invalid ${label} "${invalid}" (expected e.g. ${example})` : true; +} interface AddHarnessScreenProps { existingHarnessNames: string[]; + existingApiKeyCredentialNames?: string[]; onComplete: (config: AddHarnessConfig) => void; onExit: () => void; } -export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: AddHarnessScreenProps) { +export function AddHarnessScreen({ + existingHarnessNames, + existingApiKeyCredentialNames = [], + onComplete, + onExit, +}: AddHarnessScreenProps) { const wizard = useAddHarnessWizard(); const jwtFlow = useJwtConfigFlow({ onComplete: jwtConfig => wizard.setJwtConfig(jwtConfig), onBack: () => wizard.goBack(), + enablePrivateEndpoint: true, }); const modelProviderItems: SelectableItem[] = useMemo( @@ -78,8 +108,21 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A ); const advancedSettingItems: SelectableItem[] = useMemo( - () => ADVANCED_SETTING_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), - [] + () => + ADVANCED_SETTING_OPTIONS + // Memory-tuning options are mode-scoped: each appears only for the memory mode the user chose, + // and managed/existing have disjoint knob sets (per the harness API). Disabled shows none. + // - memory-managed-tuning → only when mode === 'managed' (strategies/event-expiry/KMS) + // - memory-existing-tuning → only when mode === 'existing' (actorId/messagesCount/topK/relevance) + // - memory-tuning (legacy) → only in the gated-off model, when memory isn't skipped + .filter(opt => { + if (opt.id === 'memory-managed-tuning') return wizard.config.memory?.mode === 'managed'; + if (opt.id === 'memory-existing-tuning') return wizard.config.memory?.mode === 'existing'; + if (opt.id === 'memory-tuning') return !wizard.config.memory && wizard.config.skipMemory !== true; + return true; + }) + .map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [wizard.config.skipMemory, wizard.config.memory] ); const toolSelectItems: SelectableItem[] = useMemo( @@ -92,6 +135,16 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A [] ); + const memoryModeItems: SelectableItem[] = useMemo( + () => MEMORY_MODE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const managedStrategyItems: SelectableItem[] = useMemo( + () => MANAGED_STRATEGY_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + const networkModeItems: SelectableItem[] = useMemo( () => NETWORK_MODE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), [] @@ -116,6 +169,8 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A const isModelProviderStep = wizard.step === 'model-provider'; const isApiFormatStep = wizard.step === 'api-format'; const isApiKeyArnStep = wizard.step === 'api-key-arn'; + const isApiBaseStep = wizard.step === 'api-base'; + const isAdditionalParamsStep = wizard.step === 'additional-params'; const isContainerStep = wizard.step === 'container'; const isContainerUriStep = wizard.step === 'container-uri'; const isContainerDockerfileStep = wizard.step === 'container-dockerfile'; @@ -128,6 +183,11 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A const isGatewayProviderArnStep = wizard.step === 'gateway-provider-arn'; const isGatewayScopesStep = wizard.step === 'gateway-scopes'; const isMemoryStep = wizard.step === 'memory'; + const isMemoryModeStep = wizard.step === 'memory-mode'; + const isMemoryStrategiesStep = wizard.step === 'memory-strategies'; + const isMemoryEventExpiryStep = wizard.step === 'memory-event-expiry'; + const isMemoryKmsStep = wizard.step === 'memory-kms'; + const isMemoryExistingRefStep = wizard.step === 'memory-existing-ref'; const isAuthorizerTypeStep = wizard.step === 'authorizerType'; const isJwtConfigStep = wizard.step === 'jwtConfig'; const isNetworkModeStep = wizard.step === 'network-mode'; @@ -138,6 +198,15 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A const isMaxIterationsStep = wizard.step === 'max-iterations'; const isMaxTokensStep = wizard.step === 'max-tokens'; const isTimeoutStep = wizard.step === 'timeout'; + const isTemperatureStep = wizard.step === 'temperature'; + const isTopPStep = wizard.step === 'top-p'; + const isTopKStep = wizard.step === 'top-k'; + const isModelMaxTokensStep = wizard.step === 'model-max-tokens'; + const isMessagesCountStep = wizard.step === 'memory-messages-count'; + const isMemoryRetrievalTopKStep = wizard.step === 'memory-retrieval-top-k'; + const isMemoryRelevanceScoreStep = wizard.step === 'memory-relevance-score'; + const isMcpHeadersStep = wizard.step === 'mcp-headers'; + const isAllowedToolsStep = wizard.step === 'allowed-tools'; const isTruncationStrategyStep = wizard.step === 'truncation-strategy'; const isSessionStoragePathStep = wizard.step === 'session-storage-path'; const isEfsArnStep = wizard.step === 'efs-arn'; @@ -146,6 +215,14 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A const isS3ArnStep = wizard.step === 's3-arn'; const isS3MountPathStep = wizard.step === 's3-mount-path'; const isS3AddAnotherStep = wizard.step === 's3-add-another'; + const isSkillsSourceTypeStep = wizard.step === 'skills-source-type'; + const isSkillPathStep = wizard.step === 'skill-path'; + const isSkillS3UriStep = wizard.step === 'skill-s3-uri'; + const isSkillGitUrlStep = wizard.step === 'skill-git-url'; + const isSkillGitPathStep = wizard.step === 'skill-git-path'; + const isSkillGitCredentialStep = wizard.step === 'skill-git-credential'; + const isSkillGitUsernameStep = wizard.step === 'skill-git-username'; + const isSkillAddAnotherStep = wizard.step === 'skill-add-another'; const isConfirmStep = wizard.step === 'confirm'; const modelProviderNav = useListNavigation({ @@ -194,6 +271,30 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A isActive: isMemoryStep, }); + const memoryModeNav = useListNavigation({ + items: memoryModeItems, + onSelect: item => wizard.setMemoryMode(item.id as 'managed' | 'existing' | 'disabled'), + onExit: () => wizard.goBack(), + isActive: isMemoryModeStep, + }); + + const initialStrategyIds = useMemo( + () => (wizard.config.memory?.mode === 'managed' ? (wizard.config.memory.strategies ?? []) : []), + // Seed once from the current config; per-keystroke selection is owned by the nav hook thereafter. + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + const managedStrategyNav = useMultiSelectNavigation({ + items: managedStrategyItems, + getId: item => item.id, + initialSelectedIds: initialStrategyIds, + onConfirm: ids => wizard.setMemoryStrategies(ids), + onExit: () => wizard.goBack(), + isActive: isMemoryStrategiesStep, + // Optional: confirming with nothing selected leaves strategies absent → service default. + requireSelection: false, + }); + const authorizerTypeNav = useListNavigation({ items: authorizerTypeItems, onSelect: item => wizard.setAuthorizerType(item.id as RuntimeAuthorizerType), @@ -222,6 +323,67 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A isActive: isTruncationStrategyStep, }); + const skillSourceTypeItems: SelectableItem[] = useMemo( + () => + SKILL_SOURCE_TYPE_OPTIONS.map(opt => ({ + id: opt.id, + title: opt.title, + description: opt.id === 'aws_skills' && !isGatedFeaturesEnabled() ? 'Coming soon' : opt.description, + disabled: opt.id === 'aws_skills' && !isGatedFeaturesEnabled(), + })), + [] + ); + + const skillSourceTypeNav = useListNavigation({ + items: skillSourceTypeItems, + onSelect: item => wizard.setSkillSourceType(item.id as 'path' | 's3' | 'git' | 'aws_skills'), + onExit: () => wizard.goBack(), + isActive: isSkillsSourceTypeStep, + isDisabled: item => item.disabled === true, + }); + + const skillGitCredentialItems: SelectableItem[] = useMemo( + () => [ + ...existingApiKeyCredentialNames.map(name => ({ + id: name, + title: name, + description: 'Use existing API key credential', + })), + { id: 'skip', title: 'Skip (no auth needed)', description: 'Repository is publicly accessible' }, + ], + [existingApiKeyCredentialNames] + ); + + const skillGitCredentialNav = useListNavigation({ + items: skillGitCredentialItems, + onSelect: item => { + wizard.submitSkillGitCredential(item.id); + }, + onExit: () => wizard.goBack(), + isActive: isSkillGitCredentialStep, + }); + + useEffect(() => { + if (isSkillGitCredentialStep && existingApiKeyCredentialNames.length === 0) { + wizard.submitSkillGitCredential('skip'); + } + }, [isSkillGitCredentialStep, existingApiKeyCredentialNames.length]); + + const skillAddAnotherItems: SelectableItem[] = useMemo( + () => [ + { id: 'add', title: 'Add another skill', description: 'Add one more skill source' }, + { id: 'done', title: 'Done', description: `${(wizard.config.skills ?? []).length} skill(s) configured` }, + ], + [wizard.config.skills] + ); + + const skillAddAnotherNav = useListNavigation({ + items: skillAddAnotherItems, + onSelect: item => wizard.submitSkillAddAnother(item.id), + onExit: () => wizard.goBack(), + isActive: isSkillAddAnotherStep, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(wizard.config), @@ -232,21 +394,31 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A const helpText = isJwtConfigStep ? jwtFlow.subStep === 'constraintPicker' ? HELP_TEXT.MULTI_SELECT - : jwtFlow.subStep === 'customClaims' - ? jwtFlow.claimsManagerMode === 'add' || jwtFlow.claimsManagerMode === 'edit' - ? '↑/↓ field · ←/→ cycle · Enter next/save · Esc cancel' - : 'Navigate · Enter select · Esc back' - : HELP_TEXT.TEXT_INPUT - : isAdvancedStep || isToolsSelectStep + : jwtFlow.subStep === 'privateEndpointType' || jwtFlow.subStep === 'vpcIpType' + ? HELP_TEXT.NAVIGATE_SELECT + : jwtFlow.subStep === 'customClaims' + ? jwtFlow.claimsManagerMode === 'add' || jwtFlow.claimsManagerMode === 'edit' + ? '↑/↓ field · ←/→ cycle · Enter next/save · Esc cancel' + : 'Navigate · Enter select · Esc back' + : jwtFlow.subStep === 'domainOverrides' + ? jwtFlow.overridesManagerMode === 'add' || jwtFlow.overridesManagerMode === 'edit' + ? HELP_TEXT.TEXT_INPUT + : 'Navigate · Enter select · Esc back' + : HELP_TEXT.TEXT_INPUT + : isAdvancedStep || isToolsSelectStep || isMemoryStrategiesStep ? 'Space toggle · Enter confirm · Esc back' : isModelProviderStep || isApiFormatStep || isMemoryStep || + isMemoryModeStep || isContainerStep || isNetworkModeStep || isTruncationStrategyStep || isAuthorizerTypeStep || - isGatewayOutboundAuthStep + isGatewayOutboundAuthStep || + isSkillsSourceTypeStep || + isSkillGitCredentialStep || + isSkillAddAnotherStep ? HELP_TEXT.NAVIGATE_SELECT : isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL @@ -269,10 +441,52 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A fields.push({ label: 'API Key ARN', value: wizard.config.apiKeyArn }); } - if (wizard.config.skipMemory !== undefined) { + if (wizard.config.apiBase) { + fields.push({ label: 'API Base URL', value: wizard.config.apiBase }); + } + + if (wizard.config.additionalParams) { + fields.push({ label: 'Additional Params', value: JSON.stringify(wizard.config.additionalParams) }); + } + + const mem = wizard.config.memory; + if (mem) { + // Mode-tagged memory (gated ON). + if (mem.mode === 'managed') { + const titled = mem.strategies?.length + ? mem.strategies.map(s => s.charAt(0) + s.slice(1).toLowerCase().replace('_', ' ')).join(', ') + : 'default strategies'; + fields.push({ label: 'Memory', value: `Managed (${titled})` }); + if (mem.eventExpiryDuration !== undefined) { + fields.push({ label: 'Memory Event Expiry', value: `${mem.eventExpiryDuration} days` }); + } + if (mem.encryptionKeyArn) { + fields.push({ label: 'Memory KMS Key', value: mem.encryptionKeyArn }); + } + } else if (mem.mode === 'existing') { + fields.push({ label: 'Memory', value: `Existing (${mem.arn ?? mem.name ?? '—'})` }); + } else { + fields.push({ label: 'Memory', value: 'Disabled' }); + } + } else if (wizard.config.skipMemory !== undefined) { + // Legacy enabled/disabled (gated OFF). fields.push({ label: 'Memory', value: wizard.config.skipMemory ? 'Disabled' : 'Enabled' }); } + if (wizard.config.messagesCount !== undefined) { + fields.push({ label: 'Memory Messages Count', value: String(wizard.config.messagesCount) }); + } + if (wizard.config.memoryTopK !== undefined) { + fields.push({ label: 'Memory Retrieval Top K', value: String(wizard.config.memoryTopK) }); + } + if (wizard.config.memoryRelevanceScore !== undefined) { + fields.push({ label: 'Memory Relevance Score', value: String(wizard.config.memoryRelevanceScore) }); + } + + if (wizard.config.allowedTools?.length) { + fields.push({ label: 'Allowed Tools', value: wizard.config.allowedTools.join(', ') }); + } + if (wizard.config.authorizerType) { fields.push({ label: 'Auth Type', @@ -298,6 +512,25 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A value: `${wizard.config.jwtConfig.customClaims.length} claim(s) configured`, }); } + const pe = wizard.config.jwtConfig.privateEndpoint; + if (pe?.selfManagedLatticeResource) { + fields.push({ + label: 'Private Endpoint', + value: `VPC Lattice (${pe.selfManagedLatticeResource.resourceConfigurationIdentifier})`, + }); + } else if (pe?.managedVpcResource) { + const v = pe.managedVpcResource; + fields.push({ + label: 'Private Endpoint', + value: `Managed VPC ${v.vpcIdentifier} · ${v.subnetIds.length} subnet(s) · ${v.endpointIpAddressType}`, + }); + } + if (wizard.config.jwtConfig.privateEndpointOverrides?.length) { + fields.push({ + label: 'Domain Overrides', + value: `${wizard.config.jwtConfig.privateEndpointOverrides.length} per-domain override(s)`, + }); + } if (wizard.config.jwtConfig.clientId) { fields.push({ label: 'Harness Credential', value: computeManagedOAuthCredentialName(wizard.config.name) }); } @@ -309,6 +542,9 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A if (wizard.config.mcpName) { fields.push({ label: 'MCP Server', value: `${wizard.config.mcpName} (${wizard.config.mcpUrl})` }); } + if (wizard.config.mcpHeaders && Object.keys(wizard.config.mcpHeaders).length > 0) { + fields.push({ label: 'MCP Headers', value: JSON.stringify(wizard.config.mcpHeaders) }); + } if (wizard.config.gatewayArn) { fields.push({ label: 'Gateway ARN', value: wizard.config.gatewayArn }); } @@ -330,6 +566,13 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A } } + if (wizard.config.skills?.length) { + for (const [i, skill] of wizard.config.skills.entries()) { + const label = skill.s3Uri ?? skill.gitUrl ?? skill.path ?? 'unknown'; + fields.push({ label: `Skill ${i + 1}`, value: label }); + } + } + if (wizard.config.containerUri) { fields.push({ label: 'Container URI', value: wizard.config.containerUri }); } @@ -370,6 +613,19 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A fields.push({ label: 'Timeout', value: `${wizard.config.timeoutSeconds}s` }); } + if (wizard.config.temperature !== undefined) { + fields.push({ label: 'Temperature', value: String(wizard.config.temperature) }); + } + if (wizard.config.topP !== undefined) { + fields.push({ label: 'Top P', value: String(wizard.config.topP) }); + } + if (wizard.config.topK !== undefined) { + fields.push({ label: 'Top K', value: String(wizard.config.topK) }); + } + if (wizard.config.modelMaxTokens !== undefined) { + fields.push({ label: 'Model Max Tokens', value: String(wizard.config.modelMaxTokens) }); + } + if (wizard.config.truncationStrategy) { fields.push({ label: 'Truncation Strategy', value: wizard.config.truncationStrategy }); } @@ -446,7 +702,11 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A {isApiFormatStep && ( @@ -455,11 +715,64 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A {isApiKeyArnStep && ( wizard.goBack()} - customValidation={value => isValidArn(value) || ARN_VALIDATION_MESSAGE} + customValidation={value => + // LiteLLM's key is optional — allow an empty value to skip it. + (wizard.config.modelProvider === 'lite_llm' && value.trim().length === 0) || + isValidArn(value) || + ARN_VALIDATION_MESSAGE + } + /> + )} + + {isApiBaseStep && ( + wizard.goBack()} + /> + )} + + {isAdditionalParamsStep && ( + { + const trimmed = value.trim(); + if (trimmed.length === 0) { + wizard.setAdditionalParams(undefined); + return; + } + wizard.setAdditionalParams(JSON.parse(trimmed) as Record); + }} + onCancel={() => wizard.goBack()} + customValidation={value => { + const trimmed = value.trim(); + if (trimmed.length === 0) return true; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return 'Additional params must be a JSON object'; + } + return true; + } catch { + return 'Additional params must be valid JSON'; + } + }} /> )} @@ -539,6 +852,41 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A /> )} + {isMcpHeadersStep && ( + { + const trimmed = value.trim(); + if (trimmed.length === 0) { + wizard.setMcpHeaders(undefined); + return; + } + wizard.setMcpHeaders(JSON.parse(trimmed) as Record); + }} + onCancel={() => wizard.goBack()} + customValidation={value => { + const trimmed = value.trim(); + if (trimmed.length === 0) return true; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return 'Headers must be a JSON object'; + } + for (const [k, v] of Object.entries(parsed)) { + if (typeof v !== 'string') return `Header "${k}" value must be a string`; + } + return true; + } catch { + return 'Headers must be valid JSON'; + } + }} + /> + )} + {isGatewayArnStep && ( )} + {isSkillsSourceTypeStep && ( + + )} + + {isSkillPathStep && ( + wizard.goBack()} + customValidation={value => (value.trim().length > 0 ? true : 'Path is required')} + /> + )} + + {isSkillS3UriStep && ( + wizard.goBack()} + customValidation={value => (value.startsWith('s3://') ? true : 'Must start with s3://')} + /> + )} + + {isSkillGitUrlStep && ( + wizard.goBack()} + customValidation={value => (value.startsWith('https://') ? true : 'Must be an HTTPS URL')} + /> + )} + + {isSkillGitPathStep && ( + wizard.goBack()} + /> + )} + + {isSkillGitCredentialStep && ( + + )} + + {isSkillGitUsernameStep && ( + wizard.goBack()} + /> + )} + + {wizard.step === 'skill-aws-skills-paths' && ( + wizard.goBack()} + /> + )} + + {isSkillAddAnotherStep && ( + + )} + {isMemoryStep && ( )} + {isMemoryModeStep && ( + + )} + + {isMemoryStrategiesStep && ( + + )} + + {isMemoryEventExpiryStep && ( + wizard.goBack()} + customValidation={value => { + if (value.trim() === '') return true; + const num = parseInt(value, 10); + return !isNaN(num) && num >= 3 && num <= 365 ? true : 'Must be an integer between 3 and 365'; + }} + /> + )} + + {isMemoryKmsStep && ( + wizard.goBack()} + customValidation={value => + value.trim() === '' || isValidArn(value.trim()) ? true : ARN_VALIDATION_MESSAGE + } + /> + )} + + {isMemoryExistingRefStep && ( + wizard.goBack()} + customValidation={value => { + const v = value.trim(); + if (v === '') return 'A memory name or ARN is required'; + if (v.startsWith('arn:') && !isValidArn(v)) return ARN_VALIDATION_MESSAGE; + return true; + }} + /> + )} + {isAuthorizerTypeStep && ( wizard.goBack()} - customValidation={value => - value.trim().length > 0 ? true : 'At least one subnet is required for VPC mode' - } + customValidation={value => validateIdList(value, SUBNET_ID_PATTERN, 'subnet', 'subnet-0abc123def456')} /> )} @@ -657,7 +1179,7 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A onSubmit={wizard.setSecurityGroups} onCancel={() => wizard.goBack()} customValidation={value => - value.trim().length > 0 ? true : 'At least one security group is required for VPC mode' + validateIdList(value, SECURITY_GROUP_ID_PATTERN, 'security group', 'sg-0abc123def456') } /> )} @@ -687,7 +1209,13 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A onCancel={() => wizard.goBack()} customValidation={value => { const num = parseInt(value, 10); - return !isNaN(num) && num >= 60 && num <= 28800 ? true : 'Must be between 60 and 28800'; + if (isNaN(num) || num < 60 || num > 28800) return 'Must be between 60 and 28800'; + // Enforce idle <= maxLifetime inline (idle-timeout is collected first) rather than + // deferring this cross-field rule to schema-write where it surfaces as a late error. + if (wizard.config.idleTimeout !== undefined && num < wizard.config.idleTimeout) { + return `Max lifetime must be >= idle timeout (${wizard.config.idleTimeout}s)`; + } + return true; }} /> )} @@ -737,6 +1265,147 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A /> )} + {isTemperatureStep && ( + wizard.goBack()} + customValidation={value => { + if (value.trim() === '') return true; + const num = parseFloat(value); + return !isNaN(num) && num >= 0 && num <= 2 ? true : 'Must be between 0.0 and 2.0'; + }} + /> + )} + + {isTopPStep && ( + wizard.goBack()} + customValidation={value => { + if (value.trim() === '') return true; + const num = parseFloat(value); + return !isNaN(num) && num >= 0 && num <= 1 ? true : 'Must be between 0.0 and 1.0'; + }} + /> + )} + + {isTopKStep && ( + wizard.goBack()} + customValidation={value => { + if (value.trim() === '') return true; + const num = parseInt(value, 10); + return !isNaN(num) && num >= 0 && num <= 500 ? true : 'Must be an integer between 0 and 500'; + }} + /> + )} + + {isModelMaxTokensStep && ( + wizard.goBack()} + customValidation={value => { + if (value.trim() === '') return true; + const num = parseInt(value, 10); + return !isNaN(num) && num > 0 ? true : 'Must be a positive integer'; + }} + /> + )} + + {isMessagesCountStep && ( + wizard.goBack()} + customValidation={value => { + if (value.trim() === '') return true; + const num = parseInt(value, 10); + return !isNaN(num) && num >= 1 ? true : 'Must be a positive integer'; + }} + /> + )} + + {isMemoryRetrievalTopKStep && ( + wizard.goBack()} + customValidation={value => { + if (value.trim() === '') return true; + const num = parseInt(value, 10); + return !isNaN(num) && num >= 1 ? true : 'Must be a positive integer'; + }} + /> + )} + + {isMemoryRelevanceScoreStep && ( + wizard.goBack()} + customValidation={value => { + if (value.trim() === '') return true; + const num = parseFloat(value); + return !isNaN(num) && num >= 0 && num <= 1 ? true : 'Must be between 0.0 and 1.0'; + }} + /> + )} + + {isAllowedToolsStep && ( + wizard.goBack()} + customValidation={value => { + if (value.trim() === '') return true; + const items = value + .split(',') + .map(s => s.trim()) + .filter(Boolean); + if (items.length === 0) return true; + const bad = items.find(t => t.length > 64 || !/^(\*|@?[^/]+(\/[^/]+)?)$/.test(t)); + return bad ? `Invalid pattern "${bad}" (use "*" or a tool name, max 64 chars)` : true; + }} + /> + )} + {isTruncationStrategyStep && ( ; containerMode?: ContainerMode; containerUri?: string; dockerfilePath?: string; maxIterations?: number; maxTokens?: number; timeoutSeconds?: number; - truncationStrategy?: 'sliding_window' | 'summarization'; + temperature?: number; + topP?: number; + topK?: number; + modelMaxTokens?: number; + /** Legacy enabled/disabled memory toggle — used only when gated features are OFF. */ + skipMemory?: boolean; + /** + * Mode-tagged memory ref — used when gated features are ON. Mirrors the schema union. + * `managed` owns memory internally; `existing` references a memory by name/arn; `disabled` opts out. + */ + memory?: + | { mode: 'managed'; strategies?: string[]; eventExpiryDuration?: number; encryptionKeyArn?: string } + | { + mode: 'existing'; + name?: string; + arn?: string; + actorId?: string; + messagesCount?: number; + topK?: number; + relevanceScore?: number; + } + | { mode: 'disabled' }; + messagesCount?: number; + memoryTopK?: number; + memoryRelevanceScore?: number; + mcpHeaders?: Record; + allowedTools?: string[]; + truncationStrategy?: 'sliding_window' | 'summarization' | 'none'; networkMode?: NetworkMode; subnets?: string[]; securityGroups?: string[]; @@ -71,6 +124,19 @@ export interface AddHarnessConfig { gatewayOutboundAuth?: 'awsIam' | 'none' | 'oauth'; gatewayProviderArn?: string; gatewayScopes?: string; + skills?: { + path?: string; + s3Uri?: string; + gitUrl?: string; + gitPath?: string; + credentialName?: string; + username?: string; + awsSkills?: string[]; + }[]; + pendingSkillSourceType?: 'path' | 's3' | 'git' | 'aws_skills'; + pendingSkillGitUrl?: string; + pendingSkillGitPath?: string; + pendingSkillCredentialName?: string; } export const HARNESS_STEP_LABELS: Record = { @@ -78,6 +144,8 @@ export const HARNESS_STEP_LABELS: Record = { 'model-provider': 'Model provider', 'api-format': 'API format', 'api-key-arn': 'API key ARN', + 'api-base': 'API base URL', + 'additional-params': 'Additional params', container: 'Custom environment', 'container-uri': 'Container URI', 'container-dockerfile': 'Dockerfile path', @@ -89,7 +157,21 @@ export const HARNESS_STEP_LABELS: Record = { 'gateway-outbound-auth': 'Gateway auth', 'gateway-provider-arn': 'Provider ARN', 'gateway-scopes': 'OAuth scopes', + 'skills-source-type': 'Skill source', + 'skill-path': 'Skill path', + 'skill-s3-uri': 'S3 URI', + 'skill-git-url': 'Git URL', + 'skill-git-path': 'Git sub-path', + 'skill-git-credential': 'Git credential', + 'skill-git-username': 'Username', + 'skill-aws-skills-paths': 'AWS Skills paths', + 'skill-add-another': 'Add skill', memory: 'Memory', + 'memory-mode': 'Memory mode', + 'memory-strategies': 'Memory strategies', + 'memory-event-expiry': 'Memory event expiry (days)', + 'memory-kms': 'Memory KMS key ARN', + 'memory-existing-ref': 'Existing memory reference', authorizerType: 'Auth type', jwtConfig: 'JWT config', 'network-mode': 'Network mode', @@ -100,6 +182,15 @@ export const HARNESS_STEP_LABELS: Record = { 'max-iterations': 'Max iterations', 'max-tokens': 'Max tokens', timeout: 'Timeout', + temperature: 'Temperature', + 'top-p': 'Top P', + 'top-k': 'Top K', + 'model-max-tokens': 'Model max tokens', + 'memory-messages-count': 'Memory messages count', + 'memory-retrieval-top-k': 'Memory retrieval top K', + 'memory-relevance-score': 'Memory relevance score', + 'mcp-headers': 'MCP headers', + 'allowed-tools': 'Allowed tools', 'truncation-strategy': 'Truncation', 'session-storage-path': 'Session storage path', 'efs-arn': 'EFS ARN', @@ -115,6 +206,7 @@ export const DEFAULT_MODEL_IDS: Record = { bedrock: 'global.anthropic.claude-sonnet-4-6', open_ai: 'gpt-5', gemini: 'gemini-2.5-flash', + lite_llm: 'anthropic/claude-sonnet-4-5', }; export const DEFAULT_BEDROCK_MANTLE_MODEL_ID = 'openai.gpt-oss-120b'; @@ -131,6 +223,11 @@ export const MODEL_PROVIDER_OPTIONS = [ title: 'Google Gemini', description: `Default: ${DEFAULT_MODEL_IDS.gemini} (requires API key ARN)`, }, + { + id: 'lite_llm' as const, + title: 'LiteLLM', + description: `Default: ${DEFAULT_MODEL_IDS.lite_llm} (API key ARN optional)`, + }, ] as const; export const BEDROCK_API_FORMAT_OPTIONS = [ @@ -169,14 +266,39 @@ export const API_FORMAT_OPTIONS = BEDROCK_API_FORMAT_OPTIONS; export const TRUNCATION_STRATEGY_OPTIONS = [ { id: 'sliding_window' as const, title: 'Sliding window', description: 'Keep most recent messages' }, { id: 'summarization' as const, title: 'Summarization', description: 'Compress older context' }, + { id: 'none' as const, title: 'None', description: 'Disable truncation' }, ] as const; export const ADVANCED_SETTING_OPTIONS = [ { id: 'tools', title: 'Tools', description: 'Add browser, code interpreter, MCP, or gateway tools' }, + { id: 'skills', title: 'Skills', description: 'Add agent skills' }, + // Two mode-scoped memory-tuning options: only the one matching the chosen memory mode is shown in + // the advanced list (see AddHarnessScreen's filter). Managed and existing have disjoint knob sets + // per the harness API, so they never both appear. Legacy (gated-off) uses 'memory-tuning'. + { + id: 'memory-tuning', + title: 'Memory tuning', + description: 'Tune messages count and retrieval (topK, relevance score)', + }, + { + id: 'memory-managed-tuning', + title: 'Memory tuning', + description: 'Managed memory: strategies, event retention, encryption key', + }, + { + id: 'memory-existing-tuning', + title: 'Memory tuning', + description: 'Existing memory: actor ID, messages count, retrieval (topK, relevance)', + }, + { id: 'allowed-tools', title: 'Allowed tools', description: 'Restrict which tools the agent may invoke' }, { id: 'auth', title: 'Authentication', description: 'Inbound auth: AWS_IAM or Custom JWT' }, { id: 'network', title: 'Network', description: 'Deploy inside a VPC with custom subnets and security groups' }, { id: 'lifecycle', title: 'Lifecycle', description: 'Set idle timeout and max session lifetime' }, - { id: 'execution', title: 'Execution limits', description: 'Cap iterations, tokens, and per-turn timeout' }, + { + id: 'execution', + title: 'Execution & sampling', + description: 'Cap iterations, tokens, timeout; tune temperature, topP, topK', + }, { id: 'truncation', title: 'Truncation', description: 'Choose how context is managed when it exceeds limits' }, { id: 'session-storage', @@ -196,6 +318,27 @@ export const MEMORY_OPTIONS = [ { id: 'enabled' as const, title: 'Enabled', description: 'Create persistent memory for this harness' }, ] as const; +/** Mode-first memory options (gated features ON). Mirrors the schema's 3-mode union. */ +export const MEMORY_MODE_OPTIONS = [ + { + id: 'managed' as const, + title: 'Managed', + description: 'AgentCore creates and manages memory for this harness (default)', + }, + { id: 'existing' as const, title: 'Existing', description: 'Reference an existing memory by name or ARN' }, + { id: 'disabled' as const, title: 'Disabled', description: 'No memory' }, +] as const; + +/** Managed-memory strategy choices (the four CFN ManagedMemoryConfiguration.Strategies values). */ +export const MANAGED_STRATEGY_OPTIONS = [ + { id: 'SEMANTIC' as const, title: 'Semantic', description: 'Extract and retrieve semantic facts' }, + { id: 'SUMMARIZATION' as const, title: 'Summarization', description: 'Summarize conversation history' }, + { id: 'USER_PREFERENCE' as const, title: 'User preference', description: 'Track user preferences' }, + { id: 'EPISODIC' as const, title: 'Episodic', description: 'Recall past episodes/sessions' }, +] as const; + +/** Keep/customize options for the managed retention + encryption tuning sub-flow. */ + export const CONTAINER_MODE_OPTIONS = [ { id: 'none' as const, title: 'Default Environment', description: 'Includes Python, Bash, File tools' }, { id: 'uri' as const, title: 'Container URI', description: 'Use a pre-built container image (ECR URI)' }, @@ -228,3 +371,14 @@ export const GATEWAY_OUTBOUND_AUTH_OPTIONS = [ { id: 'none', title: 'None', description: 'No authentication headers' }, { id: 'oauth', title: 'OAuth', description: 'Bearer token via AgentCore Identity credential provider' }, ]; + +export const SKILL_SOURCE_TYPE_OPTIONS = [ + { id: 'path' as const, title: 'Path', description: 'Path to an installed skill in the environment' }, + { id: 's3' as const, title: 'S3', description: 'S3 URI (s3://bucket/path)' }, + { id: 'git' as const, title: 'Git', description: 'HTTPS git repository URL' }, + { + id: 'aws_skills' as const, + title: 'AWS Skills', + description: 'Built-in AWS skills (github.com/aws/agent-toolkit-for-aws/tree/main/skills)', + }, +] as const; diff --git a/src/cli/tui/screens/harness/useAddHarnessWizard.ts b/src/cli/tui/screens/harness/useAddHarnessWizard.ts index f181ec8b6..ccf057293 100644 --- a/src/cli/tui/screens/harness/useAddHarnessWizard.ts +++ b/src/cli/tui/screens/harness/useAddHarnessWizard.ts @@ -1,5 +1,5 @@ import type { HarnessApiFormat, HarnessModelProvider, NetworkMode, RuntimeAuthorizerType } from '../../../../schema'; -import { isPreviewEnabled } from '../../../feature-flags'; +import { isGatedFeaturesEnabled, isPreviewEnabled } from '../../../feature-flags'; import type { JwtConfig } from '../../components/jwt-config'; import { HARNESS_FILESYSTEM_STEP_NAMES, useFilesystemMountState } from '../../hooks/useFilesystemMountState'; import type { AddHarnessConfig, AddHarnessStep, AdvancedSetting, ContainerMode } from './types'; @@ -8,6 +8,11 @@ import { useCallback, useMemo, useState } from 'react'; const ADVANCED_SETTING_ORDER: AdvancedSetting[] = [ 'tools', + 'skills', + 'memory-tuning', + 'memory-managed-tuning', + 'memory-existing-tuning', + 'allowed-tools', 'auth', 'network', 'lifecycle', @@ -18,6 +23,11 @@ const ADVANCED_SETTING_ORDER: AdvancedSetting[] = [ const SETTING_TO_FIRST_STEP: Record = { tools: 'tools-select', + skills: 'skills-source-type', + 'memory-tuning': 'memory-messages-count', + 'memory-managed-tuning': 'memory-strategies', + 'memory-existing-tuning': 'memory-messages-count', + 'allowed-tools': 'allowed-tools', auth: 'authorizerType', network: 'network-mode', lifecycle: 'idle-timeout', @@ -47,6 +57,12 @@ function getDefaultConfig(): AddHarnessConfig { name: '', modelProvider: 'bedrock', modelId: DEFAULT_MODEL_IDS.bedrock, + // Managed memory is the default for new harnesses when the gated feature is on (strategies left + // absent → service default; tunable under Advanced). When off, the legacy enabled/disabled + // `memory` step drives skipMemory instead and this stays undefined. + ...(isGatedFeaturesEnabled() && { + memory: { mode: 'managed' as const }, + }), }; } @@ -66,6 +82,10 @@ export function useAddHarnessWizard() { steps.push('api-key-arn'); } + if (config.modelProvider === 'lite_llm') { + steps.push('api-base', 'additional-params'); + } + steps.push('container'); if (config.containerMode === 'uri') { steps.push('container-uri'); @@ -73,14 +93,25 @@ export function useAddHarnessWizard() { steps.push('container-dockerfile'); } - steps.push('memory'); + if (isGatedFeaturesEnabled()) { + // Main path is just the mode pick. Managed defaults to the service's own strategy set (nothing + // more to ask); existing REQUIRES a name/ARN so it's collected here; disabled needs nothing. + // All other knobs (managed strategies/expiry/KMS, existing tuning) live under Advanced → Memory tuning. + steps.push('memory-mode'); + if (config.memory?.mode === 'existing') { + steps.push('memory-existing-ref'); + } + } else { + // Legacy enabled/disabled memory step. + steps.push('memory'); + } steps.push('advanced'); if (advancedSettings.includes('tools')) { steps.push('tools-select'); if (config.selectedTools?.includes('remote_mcp')) { - steps.push('mcp-name', 'mcp-url'); + steps.push('mcp-name', 'mcp-url', 'mcp-headers'); } if (config.selectedTools?.includes('agentcore_gateway')) { steps.push('gateway-arn'); @@ -91,6 +122,20 @@ export function useAddHarnessWizard() { } } + if (advancedSettings.includes('skills')) { + steps.push('skills-source-type'); + if (config.pendingSkillSourceType === 'path') { + steps.push('skill-path'); + } else if (config.pendingSkillSourceType === 's3') { + steps.push('skill-s3-uri'); + } else if (config.pendingSkillSourceType === 'git') { + steps.push('skill-git-url', 'skill-git-path', 'skill-git-credential', 'skill-git-username'); + } else if (config.pendingSkillSourceType === 'aws_skills') { + steps.push('skill-aws-skills-paths'); + } + steps.push('skill-add-another'); + } + if (advancedSettings.includes('auth')) { steps.push('authorizerType'); if (config.authorizerType === 'CUSTOM_JWT') { @@ -105,12 +150,34 @@ export function useAddHarnessWizard() { } } + // Mode-scoped memory tuning (gated on). Only the advanced option matching the chosen memory mode is + // offered (see AddHarnessScreen's filter), so these are mutually exclusive: managed and existing have + // disjoint knob sets per the harness API. + if (advancedSettings.includes('memory-managed-tuning') && config.memory?.mode === 'managed') { + steps.push('memory-strategies', 'memory-event-expiry', 'memory-kms'); + } + if (advancedSettings.includes('memory-existing-tuning') && config.memory?.mode === 'existing') { + steps.push('memory-messages-count', 'memory-retrieval-top-k', 'memory-relevance-score'); + } + // Legacy tuning (gated off): the old flat topK/relevance/messages knobs. + if (advancedSettings.includes('memory-tuning') && !isGatedFeaturesEnabled()) { + steps.push('memory-messages-count', 'memory-retrieval-top-k', 'memory-relevance-score'); + } + + if (advancedSettings.includes('allowed-tools')) { + steps.push('allowed-tools'); + } + if (advancedSettings.includes('lifecycle')) { steps.push('idle-timeout', 'max-lifetime'); } if (advancedSettings.includes('execution')) { - steps.push('max-iterations', 'max-tokens', 'timeout'); + steps.push('max-iterations', 'max-tokens', 'timeout', 'temperature', 'top-p'); + if (config.modelProvider === 'gemini') { + steps.push('top-k'); + } + steps.push('model-max-tokens'); } if (advancedSettings.includes('truncation')) { @@ -133,6 +200,9 @@ export function useAddHarnessWizard() { config.networkMode, config.selectedTools, config.gatewayOutboundAuth, + config.pendingSkillSourceType, + config.skills, + config.memory?.mode, advancedSettings, ]); @@ -223,6 +293,22 @@ export function useAddHarnessWizard() { } return; } + if (step === 'skills-source-type') { + if ((config.skills?.length ?? 0) > 0) { + setStep('skill-add-another'); + } else { + const idx = allSteps.indexOf('skills-source-type'); + const prev = allSteps[idx - 1]; + if (prev) setStep(prev); + } + return; + } + if (step === 'skill-add-another') { + const idx = allSteps.indexOf('skills-source-type'); + const prev = allSteps[idx - 1]; + if (prev) setStep(prev); + return; + } const idx = allSteps.indexOf(step); const prevStep = allSteps[idx - 1]; if (prevStep) setStep(prevStep); @@ -233,6 +319,7 @@ export function useAddHarnessWizard() { editingS3Index, config.efsAccessPoints, config.s3AccessPoints, + config.skills, resetFilesystemState, ]); @@ -254,8 +341,18 @@ export function useAddHarnessWizard() { ); const setModelProvider = useCallback((modelProvider: HarnessModelProvider) => { - setConfig(c => ({ ...c, modelProvider, modelId: DEFAULT_MODEL_IDS[modelProvider], apiFormat: undefined })); - if (modelProvider === 'bedrock' && isPreviewEnabled()) { + setConfig(c => ({ + ...c, + modelProvider, + modelId: DEFAULT_MODEL_IDS[modelProvider], + apiFormat: undefined, + // apiBase / additionalParams only apply to lite_llm — clear them when switching away. + ...(modelProvider !== 'lite_llm' && { apiBase: undefined, additionalParams: undefined }), + })); + // bedrock and open_ai both have a preview-gated api-format step that sits before api-key-arn + // in allSteps — route through it for BOTH (open_ai previously jumped straight to api-key-arn, + // making api-format forward-unreachable and leaving a false ✓ on the skipped step). + if ((modelProvider === 'bedrock' || modelProvider === 'open_ai') && isPreviewEnabled()) { setStep('api-format'); } else if (modelProvider !== 'bedrock') { setStep('api-key-arn'); @@ -264,20 +361,30 @@ export function useAddHarnessWizard() { } }, []); - const setApiFormat = useCallback((apiFormat: HarnessApiFormat) => { - setConfig(c => { - if (c.modelProvider === 'bedrock') { - const isMantle = apiFormat !== 'converse_stream'; - return { - ...c, - apiFormat: isMantle ? apiFormat : undefined, - modelId: isMantle ? DEFAULT_BEDROCK_MANTLE_MODEL_ID : DEFAULT_MODEL_IDS.bedrock, - }; - } - return { ...c, apiFormat }; - }); - setStep('container'); - }, []); + const setApiFormat = useCallback( + (apiFormat: HarnessApiFormat) => { + let provider: HarnessModelProvider = 'bedrock'; + setConfig(c => { + provider = c.modelProvider; + if (c.modelProvider === 'bedrock') { + const isMantle = apiFormat !== 'converse_stream'; + return { + ...c, + apiFormat: isMantle ? apiFormat : undefined, + modelId: isMantle ? DEFAULT_BEDROCK_MANTLE_MODEL_ID : DEFAULT_MODEL_IDS.bedrock, + }; + } + return { ...c, apiFormat }; + }); + // Advance to the natural next step instead of hard-coding 'container'. For open_ai the next + // step is the REQUIRED api-key-arn — hard-coding 'container' skipped it, so a Back→api-format + // →select path reached Confirm with apiKeyArn undefined and failed hard at write time. + const next = nextStep('api-format'); + if (next) setStep(next); + else setStep(provider === 'bedrock' ? 'container' : 'api-key-arn'); + }, + [nextStep] + ); const setApiKeyArn = useCallback( (apiKeyArn: string) => { @@ -288,6 +395,24 @@ export function useAddHarnessWizard() { [nextStep] ); + const setApiBase = useCallback( + (apiBase: string) => { + setConfig(c => ({ ...c, apiBase: apiBase || undefined })); + const next = nextStep('api-base'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setAdditionalParams = useCallback( + (additionalParams: Record | undefined) => { + setConfig(c => ({ ...c, additionalParams })); + const next = nextStep('additional-params'); + if (next) setStep(next); + }, + [nextStep] + ); + const setContainerMode = useCallback((containerMode: ContainerMode) => { setConfig(c => ({ ...c, containerMode, containerUri: undefined, dockerfilePath: undefined })); if (containerMode === 'uri') { @@ -295,7 +420,8 @@ export function useAddHarnessWizard() { } else if (containerMode === 'dockerfile') { setStep('container-dockerfile'); } else { - setStep('memory'); + // Route to the first memory step: the mode picker (gated on) or the legacy toggle (gated off). + setStep(isGatedFeaturesEnabled() ? 'memory-mode' : 'memory'); } }, []); @@ -359,9 +485,14 @@ export function useAddHarnessWizard() { [nextStep] ); - const setMcpUrl = useCallback( - (mcpUrl: string) => { - setConfig(c => ({ ...c, mcpUrl })); + const setMcpUrl = useCallback((mcpUrl: string) => { + setConfig(c => ({ ...c, mcpUrl })); + setStep('mcp-headers'); + }, []); + + const setMcpHeaders = useCallback( + (headers: Record | undefined) => { + setConfig(c => ({ ...c, mcpHeaders: headers })); if (config.selectedTools?.includes('agentcore_gateway')) { setStep('gateway-arn'); } else { @@ -409,6 +540,65 @@ export function useAddHarnessWizard() { setStep('advanced'); }, []); + // --- Mode-first memory sub-flow setters (gated features ON) --- + + const setMemoryMode = useCallback((mode: 'managed' | 'existing' | 'disabled') => { + // Managed seeds nothing beyond the mode — strategies/expiry/KMS are opt-in under Advanced, and an + // absent strategy set means "use the service default". Existing collects its required ref next. + setConfig(c => ({ ...c, memory: { mode } })); + if (mode === 'existing') { + setStep('memory-existing-ref'); + } else { + // Managed / disabled have nothing more on the main path → continue to Advanced. + setStep('advanced'); + } + }, []); + + const setMemoryStrategies = useCallback( + (strategies: string[]) => { + setConfig(c => + c.memory?.mode === 'managed' + ? { ...c, memory: { ...c.memory, strategies: strategies.length > 0 ? strategies : undefined } } + : c + ); + const next = nextStep('memory-strategies'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMemoryEventExpiry = useCallback( + (raw: string) => { + const days = raw.trim() === '' ? undefined : parseInt(raw, 10); + setConfig(c => (c.memory?.mode === 'managed' ? { ...c, memory: { ...c.memory, eventExpiryDuration: days } } : c)); + const next = nextStep('memory-event-expiry'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMemoryKms = useCallback( + (raw: string) => { + const encryptionKeyArn = raw.trim() === '' ? undefined : raw.trim(); + setConfig(c => (c.memory?.mode === 'managed' ? { ...c, memory: { ...c.memory, encryptionKeyArn } } : c)); + const next = nextStep('memory-kms'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMemoryExistingRef = useCallback((raw: string) => { + const value = raw.trim(); + // An ARN goes to `arn`, anything else is treated as a project memory name. + const isArn = value.startsWith('arn:'); + setConfig(c => ({ + ...c, + memory: { mode: 'existing', ...(isArn ? { arn: value } : { name: value }) }, + })); + // Existing-ref is the last main-path memory step → continue to Advanced. + setStep('advanced'); + }, []); + const setAuthorizerType = useCallback( (authorizerType: RuntimeAuthorizerType) => { setConfig(c => ({ ...c, authorizerType, jwtConfig: undefined })); @@ -520,8 +710,95 @@ export function useAddHarnessWizard() { [nextStep] ); + const setTemperature = useCallback( + (raw: string) => { + const temperature = raw.trim() === '' ? undefined : parseFloat(raw); + setConfig(c => ({ ...c, temperature })); + const next = nextStep('temperature'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setTopP = useCallback( + (raw: string) => { + const topP = raw.trim() === '' ? undefined : parseFloat(raw); + setConfig(c => ({ ...c, topP })); + const next = nextStep('top-p'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setTopK = useCallback( + (raw: string) => { + const topK = raw.trim() === '' ? undefined : parseInt(raw, 10); + setConfig(c => ({ ...c, topK })); + const next = nextStep('top-k'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setModelMaxTokens = useCallback( + (raw: string) => { + const modelMaxTokens = raw.trim() === '' ? undefined : parseInt(raw, 10); + setConfig(c => ({ ...c, modelMaxTokens })); + const next = nextStep('model-max-tokens'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMessagesCount = useCallback( + (raw: string) => { + const messagesCount = raw.trim() === '' ? undefined : parseInt(raw, 10); + setConfig(c => ({ ...c, messagesCount })); + const next = nextStep('memory-messages-count'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMemoryTopK = useCallback( + (raw: string) => { + const memoryTopK = raw.trim() === '' ? undefined : parseInt(raw, 10); + setConfig(c => ({ ...c, memoryTopK })); + const next = nextStep('memory-retrieval-top-k'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setMemoryRelevanceScore = useCallback( + (raw: string) => { + const memoryRelevanceScore = raw.trim() === '' ? undefined : parseFloat(raw); + setConfig(c => ({ ...c, memoryRelevanceScore })); + const next = nextStep('memory-relevance-score'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setAllowedTools = useCallback( + (raw: string) => { + const trimmed = raw.trim(); + const allowedTools = + trimmed === '' + ? undefined + : trimmed + .split(',') + .map(s => s.trim()) + .filter(Boolean); + setConfig(c => ({ ...c, allowedTools })); + const next = nextStep('allowed-tools'); + if (next) setStep(next); + }, + [nextStep] + ); + const setTruncationStrategy = useCallback( - (truncationStrategy: 'sliding_window' | 'summarization') => { + (truncationStrategy: 'sliding_window' | 'summarization' | 'none') => { setConfig(c => ({ ...c, truncationStrategy })); const next = nextStep('truncation-strategy'); if (next) setStep(next); @@ -538,6 +815,100 @@ export function useAddHarnessWizard() { [nextStep] ); + const setSkillSourceType = useCallback((sourceType: 'path' | 's3' | 'git' | 'aws_skills') => { + setConfig(c => ({ ...c, pendingSkillSourceType: sourceType })); + if (sourceType === 'path') setStep('skill-path'); + else if (sourceType === 's3') setStep('skill-s3-uri'); + else if (sourceType === 'aws_skills') setStep('skill-aws-skills-paths'); + else setStep('skill-git-url'); + }, []); + + const submitSkillPath = useCallback((path: string) => { + setConfig(c => ({ + ...c, + skills: [...(c.skills ?? []), { path }], + pendingSkillSourceType: undefined, + })); + setStep('skill-add-another'); + }, []); + + const submitSkillS3 = useCallback((s3Uri: string) => { + setConfig(c => ({ + ...c, + skills: [...(c.skills ?? []), { s3Uri }], + pendingSkillSourceType: undefined, + })); + setStep('skill-add-another'); + }, []); + + const submitSkillGitUrl = useCallback((gitUrl: string) => { + setConfig(c => ({ ...c, pendingSkillGitUrl: gitUrl })); + setStep('skill-git-path'); + }, []); + + const submitSkillGitPath = useCallback((gitPath: string) => { + setConfig(c => ({ ...c, pendingSkillGitPath: gitPath || undefined })); + setStep('skill-git-credential'); + }, []); + + const submitSkillGitCredential = useCallback((selection: string) => { + if (selection === 'skip') { + setConfig(c => ({ ...c, pendingSkillCredentialName: undefined })); + setStep('skill-git-username'); + } else { + // selection is a credential name (existing or newly created) + setConfig(c => ({ ...c, pendingSkillCredentialName: selection })); + setStep('skill-git-username'); + } + }, []); + + const submitSkillGitUsername = useCallback((username: string) => { + setConfig(c => { + const skill: NonNullable[number] = { + gitUrl: c.pendingSkillGitUrl, + ...(c.pendingSkillGitPath && { gitPath: c.pendingSkillGitPath }), + ...(c.pendingSkillCredentialName && { + credentialName: c.pendingSkillCredentialName, + ...(username && { username }), + }), + }; + return { + ...c, + skills: [...(c.skills ?? []), skill], + pendingSkillSourceType: undefined, + pendingSkillGitUrl: undefined, + pendingSkillGitPath: undefined, + pendingSkillCredentialName: undefined, + }; + }); + setStep('skill-add-another'); + }, []); + + const submitSkillAwsSkillsPaths = useCallback((pathsStr: string) => { + const paths = pathsStr + .split(',') + .map(s => s.trim()) + .filter(Boolean); + setConfig(c => ({ + ...c, + skills: [...(c.skills ?? []), { awsSkills: paths }], + pendingSkillSourceType: undefined, + })); + setStep('skill-add-another'); + }, []); + + const submitSkillAddAnother = useCallback( + (choice: string) => { + if (choice === 'add') { + setStep('skills-source-type'); + } else { + const next = getNextAdvancedStep(advancedSettings, 'skills'); + setStep(next ?? 'confirm'); + } + }, + [advancedSettings] + ); + const reset = useCallback(() => { setConfig(getDefaultConfig()); setStep('name'); @@ -556,6 +927,8 @@ export function useAddHarnessWizard() { setModelProvider, setApiFormat, setApiKeyArn, + setApiBase, + setAdditionalParams, setContainerMode, setContainerUri, setDockerfilePath, @@ -568,6 +941,11 @@ export function useAddHarnessWizard() { setGatewayProviderArn, setGatewayScopes, setMemoryEnabled, + setMemoryMode, + setMemoryStrategies, + setMemoryEventExpiry, + setMemoryKms, + setMemoryExistingRef, setAuthorizerType, setJwtConfig, setNetworkMode, @@ -578,6 +956,15 @@ export function useAddHarnessWizard() { setMaxIterations, setMaxTokens, setTimeoutSeconds, + setTemperature, + setTopP, + setTopK, + setModelMaxTokens, + setMessagesCount, + setMemoryTopK, + setMemoryRelevanceScore, + setAllowedTools, + setMcpHeaders, setTruncationStrategy, setSessionStoragePath, pendingEfsArn, @@ -590,6 +977,15 @@ export function useAddHarnessWizard() { submitS3Arn, submitS3MountPath, submitS3AddAnother, + setSkillSourceType, + submitSkillPath, + submitSkillS3, + submitSkillGitUrl, + submitSkillGitPath, + submitSkillGitCredential, + submitSkillGitUsername, + submitSkillAwsSkillsPaths, + submitSkillAddAnother, reset, }; } diff --git a/src/cli/tui/screens/import/ArnInputScreen.tsx b/src/cli/tui/screens/import/ArnInputScreen.tsx index 9381ca7b8..5dc9237bc 100644 --- a/src/cli/tui/screens/import/ArnInputScreen.tsx +++ b/src/cli/tui/screens/import/ArnInputScreen.tsx @@ -4,7 +4,8 @@ import { Screen } from '../../components/Screen'; import { TextInput } from '../../components/TextInput'; import { HELP_TEXT } from '../../constants'; -const ARN_PATTERN = /^arn:[^:]+:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator|online-evaluation-config)\/.+$/; +const ARN_PATTERN = + /^arn:[^:]+:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator|gateway|online-evaluation-config)\/.+$/; function validateArn(value: string): true | string { if (!ARN_PATTERN.test(value)) { diff --git a/src/cli/tui/screens/insights-jobs/InsightsJobsScreen.tsx b/src/cli/tui/screens/insights-jobs/InsightsJobsScreen.tsx new file mode 100644 index 000000000..edaba804c --- /dev/null +++ b/src/cli/tui/screens/insights-jobs/InsightsJobsScreen.tsx @@ -0,0 +1,383 @@ +import type { FailureAnalysisResult, GetBatchEvaluationResult } from '../../../aws/agentcore-batch-evaluation'; +import { getBatchEvaluation } from '../../../aws/agentcore-batch-evaluation'; +import type { InsightsRunRecord } from '../../../operations/insights'; +import { listInsightsRuns } from '../../../operations/insights'; +import { Panel, Screen } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { Box, Text, useInput, useStdout } from 'ink'; +import React, { useEffect, useMemo, useState } from 'react'; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function formatShortDate(timestamp: string): string { + const d = new Date(timestamp); + const mon = MONTHS[d.getMonth()]; + const day = d.getDate(); + const h = d.getHours(); + const m = d.getMinutes().toString().padStart(2, '0'); + const ampm = h >= 12 ? 'PM' : 'AM'; + const h12 = h % 12 || 12; + return `${mon} ${day} ${h12}:${m} ${ampm}`; +} + +function statusColor(status: string): string { + if (status === 'COMPLETED' || status === 'SUCCEEDED') return 'green'; + if (status === 'FAILED') return 'red'; + if (status === 'IN_PROGRESS' || status === 'PENDING') return 'yellow'; + return 'gray'; +} + +const CHROME_LINES = 9; + +// ───────────────────────────────────────────────────────────────────────────── +// List view +// ───────────────────────────────────────────────────────────────────────────── + +function InsightsJobsListView({ + records, + onSelect, + onExit, + availableHeight, +}: { + records: InsightsRunRecord[]; + onSelect: (record: InsightsRunRecord) => void; + onExit: () => void; + availableHeight: number; +}) { + const nav = useListNavigation({ + items: records, + onSelect: item => onSelect(item), + onExit, + isActive: true, + }); + + const maxVisible = Math.max(1, availableHeight - 3); + const visible = useMemo(() => { + let start = 0; + if (nav.selectedIndex >= maxVisible) { + start = nav.selectedIndex - maxVisible + 1; + } + return { items: records.slice(start, start + maxVisible), startIdx: start }; + }, [records, nav.selectedIndex, maxVisible]); + + return ( + + + Insights Jobs + + {records.length} insights run{records.length !== 1 ? 's' : ''} + + + {visible.items.map((rec, vIdx) => { + const idx = visible.startIdx + vIdx; + const selected = idx === nav.selectedIndex; + const date = rec.createdAt ? formatShortDate(rec.createdAt) : 'unknown'; + + return ( + + {selected ? '>' : ' '} + {date.padEnd(16)} + {rec.status.padEnd(12)} + {rec.name || rec.batchEvaluationId} + + ); + })} + {visible.startIdx + maxVisible < records.length && ( + {records.length - visible.startIdx - maxVisible} more + )} + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Results view +// ───────────────────────────────────────────────────────────────────────────── + +function InsightsResultsView({ record, onBack }: { record: InsightsRunRecord; onBack: () => void }) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [failureAnalysis, setFailureAnalysis] = useState(undefined); + const [totalSessions, setTotalSessions] = useState(0); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const result: GetBatchEvaluationResult = await getBatchEvaluation({ + region: record.region, + batchEvaluationId: record.batchEvaluationId, + }); + if (cancelled) return; + if (result.status !== 'COMPLETED' && result.status !== 'COMPLETEDWITHERRORS') { + setError(`Job has status ${result.status}. Results are only available for completed jobs.`); + } else { + setFailureAnalysis(result.failureAnalysisResult); + setTotalSessions(result.evaluationResults?.totalNumberOfSessions ?? 0); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [record.batchEvaluationId, record.region]); + + useInput((input, key) => { + if (key.escape || input === 'b') { + onBack(); + } + }); + + if (loading) { + return ( + + Loading results... + + ); + } + + if (error) { + return ( + + + {error} + + Press Esc or B to go back + + + + ); + } + + const categories = failureAnalysis?.failureCategories ?? []; + + if (categories.length === 0) { + return ( + + + No failure categories found in this insights run. + + Press Esc or B to go back + + + + ); + } + + return ( + + + Insights Results: {record.name || record.batchEvaluationId} + + Sessions: {totalSessions} | Clusters: {categories.length} + + + {categories.map((cat, i) => { + const failureCount = cat.rootCauses?.length ?? 0; + const pct = totalSessions > 0 ? Math.round((failureCount / totalSessions) * 100) : 0; + const impact = pct >= 20 ? 'HIGH IMPACT' : pct >= 10 ? 'MEDIUM' : ''; + + return ( + + + + #{i + 1} ({pct}% of sessions) + + {impact ? = 20 ? 'red' : 'yellow'}> {impact} : null} + + + {' '}Category: {cat.failureCategoryName ?? 'Unknown'} + + {cat.failureCategoryDescription && ( + + {' '} + {cat.failureCategoryDescription} + + )} + {(cat.rootCauses ?? []).map((rc, rcIdx) => ( + + Root cause: {rc.rootCauseDescription ?? rc.rootCauseCategory ?? 'Unknown'} + {rc.recommendation && Fix: {rc.recommendation}} + {rc.relatedSessions?.[0]?.recommendationType && ( + Fix type: {rc.relatedSessions[0].recommendationType} + )} + + ))} + + ); + })} + + + Press Esc or B to go back + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Detail view +// ───────────────────────────────────────────────────────────────────────────── + +function InsightsJobDetailView({ + record, + onBack, + onViewResults, +}: { + record: InsightsRunRecord; + onBack: () => void; + onViewResults: () => void; +}) { + const isCompleted = record.status === 'COMPLETED' || record.status === 'COMPLETEDWITHERRORS'; + + useInput((input, key) => { + if (key.escape || input === 'b') { + onBack(); + } + if ((input === 'v' || input === 'V') && isCompleted) { + onViewResults(); + } + }); + + return ( + + + + ID: {record.batchEvaluationId} + + + Status: {record.status} + + + Insights type(s): {record.insights.join(', ')} + + {record.agent && ( + + Agent: {record.agent} + + )} + {record.createdAt && ( + + Started: {new Date(record.createdAt).toLocaleString()} + + )} + {record.completedAt && ( + + Completed: {new Date(record.completedAt).toLocaleString()} + + )} + + + Sessions: + + {' '}total: {record.sessionCount ?? 'N/A'} + {record.sessionsCompleted != null && , completed: {record.sessionsCompleted}} + {record.sessionsFailed != null && record.sessionsFailed > 0 && ( + , failed: {record.sessionsFailed} + )} + + + + + + To generate a recommendation: agentcore run recommendation --from-insights {record.batchEvaluationId} + + + + + {isCompleted ? 'V view results - ' : ''}G generate recommendation - Esc back + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main screen +// ───────────────────────────────────────────────────────────────────────────── + +interface InsightsJobsScreenProps { + onExit: () => void; +} + +export function InsightsJobsScreen({ onExit }: InsightsJobsScreenProps) { + const { stdout } = useStdout(); + const terminalHeight = stdout?.rows ?? 24; + const availableHeight = Math.max(6, terminalHeight - CHROME_LINES); + + const [selectedRecord, setSelectedRecord] = useState(null); + const [viewingResults, setViewingResults] = useState(false); + + const [records, loaded, error] = useMemo(() => { + try { + return [listInsightsRuns(), true, null] as const; + } catch (err) { + return [[] as InsightsRunRecord[], true, err instanceof Error ? err.message : String(err)] as const; + } + }, []); + + if (!loaded) { + return ( + + Loading... + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (records.length === 0) { + return ( + + + No insights runs found. Run `agentcore run insights` to get started. + + + ); + } + + const helpText = viewingResults + ? 'Esc/B back to detail' + : selectedRecord + ? 'V view results - Esc/B back to list' + : HELP_TEXT.NAVIGATE_SELECT; + + return ( + + {viewingResults && selectedRecord ? ( + setViewingResults(false)} /> + ) : selectedRecord ? ( + setSelectedRecord(null)} + onViewResults={() => setViewingResults(true)} + /> + ) : ( + + )} + + ); +} diff --git a/src/cli/tui/screens/insights-jobs/index.ts b/src/cli/tui/screens/insights-jobs/index.ts new file mode 100644 index 000000000..a2d535983 --- /dev/null +++ b/src/cli/tui/screens/insights-jobs/index.ts @@ -0,0 +1 @@ +export { InsightsJobsScreen } from './InsightsJobsScreen'; diff --git a/src/cli/tui/screens/job-detail/ABTestDetailView.tsx b/src/cli/tui/screens/job-detail/ABTestDetailView.tsx new file mode 100644 index 000000000..7b742bd22 --- /dev/null +++ b/src/cli/tui/screens/job-detail/ABTestDetailView.tsx @@ -0,0 +1,235 @@ +import { getErrorMessage } from '../../../errors'; +import { isTerminal } from '../../../operations/jobs'; +import type { ABTestJobRecord, DebugCheckResult, JobEngine } from '../../../operations/jobs'; +import { getInvocationUrl } from '../../../operations/jobs/ab-test/format'; +import { Panel } from '../../components'; +import { lifecycleColor, statusColor } from './helpers'; +import { Box, Text, useInput } from 'ink'; +import React, { useCallback, useState } from 'react'; + +type ActionState = 'idle' | 'working' | 'error'; + +/** + * Shared presentational detail view for an A/B-test job. Renders the panel body + * only (the caller supplies the surrounding `Screen`). It owns the lifecycle + * action keybindings (stop/pause/resume/promote) and the debug check action. + * + * Back/exit semantics are caller-controlled: `backKey` is the letter that, like + * Escape, invokes `onBack`. Flow A (`agentcore view ab-test `) uses `'q'` + * ("exit"); the TUI history flow uses `'b'` ("back to list"). + */ +export function ABTestDetailView({ + record, + engine, + onBack, + onUpdate, + backKey = 'b', + backLabel = 'back', +}: { + record: ABTestJobRecord; + engine: JobEngine; + onBack: () => void; + onUpdate: (record: ABTestJobRecord) => void; + backKey?: string; + backLabel?: string; +}) { + const [actionState, setActionState] = useState('idle'); + const [actionError, setActionError] = useState(null); + const [debugResults, setDebugResults] = useState(null); + const [debugLoading, setDebugLoading] = useState(false); + + const caps = engine.capabilities('ab-test'); + const terminal = isTerminal(record); + const canStop = caps.canStop && !terminal; + const canPause = caps.canPause && record.lifecycleStatus === 'RUNNING'; + const canResume = caps.canPause && record.lifecycleStatus === 'PAUSED'; + const canPromote = caps.canPromote && !terminal; + + const runAction = useCallback( + async (fn: () => Promise<{ success: boolean; error?: { message: string } }>) => { + setActionState('working'); + setActionError(null); + try { + const result = await fn(); + if (!result.success) { + setActionState('error'); + setActionError(result.error?.message ?? 'Action failed'); + return; + } + const refreshed = await engine.get('ab-test', record.id); + setActionState('idle'); + if (refreshed) onUpdate(refreshed); + } catch (err) { + setActionState('error'); + setActionError(getErrorMessage(err)); + } + }, + [engine, record.id, onUpdate] + ); + + const handleDebug = useCallback(async () => { + setDebugLoading(true); + setDebugResults(null); + try { + const result = await engine.debug('ab-test', record.id); + if (result.success) { + setDebugResults(result.checks); + } else { + setDebugResults([{ label: 'Debug', status: 'fail', detail: result.error.message }]); + } + } catch { + setDebugResults([{ label: 'Debug', status: 'fail', detail: 'Failed to run debug checks' }]); + } + setDebugLoading(false); + }, [engine, record.id]); + + useInput((input, key) => { + if (actionState === 'working' || debugLoading) return; + if (key.escape || input === backKey) { + onBack(); + return; + } + const ch = input.toLowerCase(); + if (ch === 's' && canStop) void runAction(() => engine.stop('ab-test', record.id)); + else if (ch === 'p' && canPause) void runAction(() => engine.pause('ab-test', record.id)); + else if (ch === 'r' && canResume) void runAction(() => engine.resume('ab-test', record.id)); + else if (ch === 'w' && canPromote) void runAction(() => engine.promote('ab-test', record.id)); + else if (ch === 'd') void handleDebug(); + }); + + const invocationUrl = getInvocationUrl(record); + const metrics = record.results?.evaluatorMetrics; + + const keyHints = [ + `Esc/${backKey.toUpperCase()} ${backLabel}`, + canStop ? 'S stop' : null, + canPause ? 'P pause' : null, + canResume ? 'R resume' : null, + canPromote ? 'W promote' : null, + 'D debug', + ].filter(Boolean); + + return ( + + + + ID: {record.id} + + + Name: {record.name} + {' '} + Mode: {record.mode} + + + Execution: {record.status} + {' '} + Lifecycle:{' '} + {record.lifecycleStatus} + + + Gateway: {record.gatewayArn} + + {invocationUrl && ( + + Invocation URL: {invocationUrl} + + )} + {record.createdAt && ( + + Started: {new Date(record.createdAt).toLocaleString()} + + )} + {record.completedAt && ( + + Stopped: {new Date(record.completedAt).toLocaleString()} + + )} + + + Variants: + {record.variants.map(v => { + const detail = v.bundleArn + ? `bundle ${v.bundleArn} @ ${v.bundleVersion}` + : v.targetName + ? `target ${v.targetName}` + : '(unspecified)'; + return ( + + {' '} + {v.name} (weight {v.weight}): {detail} + + ); + })} + + + {metrics && metrics.length > 0 ? ( + + Results: + {metrics.map(m => ( + + {m.evaluatorArn} + + {' '}C (n={m.controlStats.sampleSize}): {m.controlStats.mean.toFixed(3)} + + {m.variantResults.map(vr => ( + + {' '} + {vr.treatmentName} (n={vr.sampleSize}): {vr.mean.toFixed(3)} + {vr.percentChange != null + ? ` (${vr.percentChange > 0 ? '+' : ''}${vr.percentChange.toFixed(1)}%)` + : ''} + {vr.isSignificant ? *significant* : null} + + ))} + + ))} + + ) : record.failureReason ? ( + + Failure: {record.failureReason} + + ) : ( + + No results available yet. + + )} + + {actionState === 'working' && ( + + Working... + + )} + {actionState === 'error' && actionError && ( + + Action failed: {actionError} + + )} + + {debugLoading && ( + + Running debug checks... + + )} + {debugResults && ( + + Debug Checks: + {debugResults.map((check, i) => { + const icon = check.status === 'pass' ? '✓' : check.status === 'warn' ? '⚠' : '✗'; + const color = check.status === 'pass' ? 'green' : check.status === 'warn' ? 'yellow' : 'red'; + return ( + + {' '} + {icon} {check.label}: {check.detail} + + ); + })} + + )} + + + {keyHints.join(' · ')} + + + + ); +} diff --git a/src/cli/tui/screens/job-detail/BatchEvalDetailView.tsx b/src/cli/tui/screens/job-detail/BatchEvalDetailView.tsx new file mode 100644 index 000000000..71c381609 --- /dev/null +++ b/src/cli/tui/screens/job-detail/BatchEvalDetailView.tsx @@ -0,0 +1,157 @@ +import { getErrorMessage } from '../../../errors'; +import { isTerminal } from '../../../operations/jobs'; +import type { BatchEvaluationJobRecord, JobEngine } from '../../../operations/jobs'; +import { Panel } from '../../components'; +import { scoreColor, statusColor } from './helpers'; +import { Box, Text, useInput } from 'ink'; +import React, { useCallback, useState } from 'react'; + +type StopState = 'idle' | 'stopping' | 'error'; + +/** + * Shared presentational detail view for a batch-evaluation job. Renders the + * panel body only (the caller supplies the surrounding `Screen`). It owns the + * stop keybinding. + * + * Back/exit semantics are caller-controlled: `backKey` is the letter that, like + * Escape, invokes `onBack`. Flow A (`agentcore view batch-evaluation `) + * uses `'q'` ("exit"); the TUI history flow uses `'b'` ("back to list"). + */ +export function BatchEvalDetailView({ + record, + engine, + onBack, + onUpdate, + backKey = 'b', + backLabel = 'back', +}: { + record: BatchEvaluationJobRecord; + engine: JobEngine; + onBack: () => void; + onUpdate: (record: BatchEvaluationJobRecord) => void; + backKey?: string; + backLabel?: string; +}) { + const [stopState, setStopState] = useState('idle'); + const [stopError, setStopError] = useState(null); + + const canStop = engine.capabilities('batch-evaluation').canStop && !isTerminal(record); + + const handleStop = useCallback(async () => { + setStopState('stopping'); + setStopError(null); + try { + const result = await engine.stop('batch-evaluation', record.id); + if (!result.success) { + setStopState('error'); + setStopError(result.error.message); + return; + } + const refreshed = await engine.get('batch-evaluation', record.id); + setStopState('idle'); + if (refreshed) onUpdate(refreshed); + } catch (err) { + setStopState('error'); + setStopError(getErrorMessage(err)); + } + }, [engine, record.id, onUpdate]); + + useInput((input, key) => { + if (key.escape || input === backKey) { + onBack(); + return; + } + if ((input === 's' || input === 'S') && canStop && stopState !== 'stopping') { + void handleStop(); + } + }); + + const evalRes = record.evaluationResults; + const summaries = evalRes?.evaluatorSummaries; + + return ( + + + + ID: {record.id} + + + Name: {record.name} + {' '} + Status: {record.status} + + + Agent: {record.agent} + {' '} + Evaluators: {record.evaluators.join(', ')} + + {record.source === 'dataset' && record.dataset && ( + + Dataset: {record.dataset.id} (version: {record.dataset.version}) + + )} + {record.createdAt && ( + + Created: {new Date(record.createdAt).toLocaleString()} + + )} + {record.completedAt && ( + + Completed: {new Date(record.completedAt).toLocaleString()} + + )} + + {evalRes?.totalNumberOfSessions != null && ( + + Sessions: {evalRes.totalNumberOfSessions} total + {evalRes.numberOfSessionsCompleted != null && , {evalRes.numberOfSessionsCompleted} completed} + {evalRes.numberOfSessionsFailed ? , {evalRes.numberOfSessionsFailed} failed : null} + + )} + + {summaries && summaries.length > 0 ? ( + + Scores (0 worst — 1 best): + {summaries.map(s => { + const avg = s.statistics?.averageScore; + const avgStr = avg != null ? avg.toFixed(2) : 'N/A'; + const color = avg != null ? scoreColor(avg) : undefined; + return ( + + {' '} + {s.evaluatorId} + {' '} + {avgStr} + {s.totalFailed ? ({s.totalFailed} failed) : null} + {s.totalEvaluated != null && [{s.totalEvaluated} evaluated]} + + ); + })} + + ) : ( + + No evaluation results available yet. + + )} + + {stopState === 'stopping' && ( + + Stopping... + + )} + {stopState === 'error' && stopError && ( + + Could not stop: {stopError} + + )} + + + + Esc/{backKey.toUpperCase()} {backLabel} + {canStop ? ' · S stop' : ''} + + + + + ); +} diff --git a/src/cli/tui/screens/job-detail/RecommendationDetailView.tsx b/src/cli/tui/screens/job-detail/RecommendationDetailView.tsx new file mode 100644 index 000000000..b59e46f47 --- /dev/null +++ b/src/cli/tui/screens/job-detail/RecommendationDetailView.tsx @@ -0,0 +1,132 @@ +import type { RecommendationJobRecord } from '../../../operations/jobs'; +import { Panel } from '../../components'; +import { shortTypeName, statusColor } from './helpers'; +import { Box, Text, useInput } from 'ink'; +import React from 'react'; + +/** + * Shared presentational detail view for a recommendation job. Renders the panel + * body only (the caller supplies the surrounding `Screen`). Recommendation jobs + * have no lifecycle actions, so this view is read-only. + * + * Back/exit semantics are caller-controlled: `backKey` is the letter that, like + * Escape, invokes `onBack`. Flow A (`agentcore view recommendation `) uses + * `'q'` ("exit"); the TUI history flow uses `'b'` ("back to list"). + */ +export function RecommendationDetailView({ + record, + onBack, + backKey = 'b', + backLabel = 'back', +}: { + record: RecommendationJobRecord; + onBack: () => void; + backKey?: string; + backLabel?: string; +}) { + useInput((input, key) => { + if (key.escape || input === backKey) { + onBack(); + } + }); + + const sysResult = record.result?.systemPromptRecommendationResult; + const toolResult = record.result?.toolDescriptionRecommendationResult; + const isFailed = record.status === 'FAILED'; + const failureText = record.failureDetail ?? record.statusReasons?.join('; '); + + return ( + + + + ID: {record.id} + + + Type: {shortTypeName(record.recommendationType)} + {' '} + Agent: {record.agent} + {' '} + Status: {record.status} + + + Evaluators: {record.evaluators.join(', ') || '(none)'} + + {record.createdAt && ( + + Created: {new Date(record.createdAt).toLocaleString()} + + )} + {record.completedAt && ( + + Completed: {new Date(record.completedAt).toLocaleString()} + + )} + + {isFailed && failureText && ( + + + Failure: + + + {failureText} + + + )} + + {sysResult?.explanation && ( + + + Explanation: + + + {sysResult.explanation} + + + )} + + {sysResult?.recommendedSystemPrompt && ( + + + Recommended System Prompt: + + + {sysResult.recommendedSystemPrompt} + + + )} + + {toolResult?.tools && toolResult.tools.length > 0 && ( + + + Recommended Tool Descriptions: + + {toolResult.tools.map(tool => ( + + {tool.toolName} + {tool.explanation && ( + + Explanation: + {tool.explanation} + + )} + {tool.recommendedToolDescription} + + ))} + + )} + + {!isFailed && !sysResult?.recommendedSystemPrompt && !(toolResult?.tools && toolResult.tools.length > 0) && ( + + No recommendation results available yet. + + )} + + + + Esc/{backKey.toUpperCase()} {backLabel} + + + + + ); +} diff --git a/src/cli/tui/screens/job-detail/helpers.ts b/src/cli/tui/screens/job-detail/helpers.ts new file mode 100644 index 000000000..b9fa2d419 --- /dev/null +++ b/src/cli/tui/screens/job-detail/helpers.ts @@ -0,0 +1,32 @@ +/** Shared color/format helpers for job-detail views (used by both the + * interactive `agentcore view ` flow and the TUI history screens). */ + +/** Color for an execution/job status string. */ +export function statusColor(status: string): string { + if (status === 'COMPLETED' || status === 'SUCCEEDED' || status === 'RUNNING') return 'green'; + if (status === 'PAUSED' || status === 'IN_PROGRESS' || status === 'PENDING' || status === 'COMPLETED_WITH_ERRORS') + return 'yellow'; + if (status === 'FAILED' || status === 'STOPPED' || status === 'CANCELLED' || status === 'NOT_FOUND') return 'red'; + return 'gray'; +} + +/** Color for an A/B-test lifecycleStatus. */ +export function lifecycleColor(status: string): string { + if (status === 'ACTIVE') return 'green'; + if (status === 'FAILED') return 'red'; + return 'gray'; +} + +/** Color for an evaluation average score (0 worst — 1 best). */ +export function scoreColor(score: number): string { + if (score >= 0.8) return 'green'; + if (score >= 0.5) return 'yellow'; + return 'red'; +} + +/** Human-friendly short name for a recommendation type. */ +export function shortTypeName(type: string): string { + if (type === 'SYSTEM_PROMPT_RECOMMENDATION') return 'System Prompt'; + if (type === 'TOOL_DESCRIPTION_RECOMMENDATION') return 'Tool Description'; + return type; +} diff --git a/src/cli/tui/screens/job-detail/index.ts b/src/cli/tui/screens/job-detail/index.ts new file mode 100644 index 000000000..106bf749e --- /dev/null +++ b/src/cli/tui/screens/job-detail/index.ts @@ -0,0 +1,4 @@ +export { ABTestDetailView } from './ABTestDetailView'; +export { BatchEvalDetailView } from './BatchEvalDetailView'; +export { RecommendationDetailView } from './RecommendationDetailView'; +export { lifecycleColor, scoreColor, shortTypeName, statusColor } from './helpers'; diff --git a/src/cli/tui/screens/knowledge-base/AddKnowledgeBaseFlow.tsx b/src/cli/tui/screens/knowledge-base/AddKnowledgeBaseFlow.tsx new file mode 100644 index 000000000..8a462a087 --- /dev/null +++ b/src/cli/tui/screens/knowledge-base/AddKnowledgeBaseFlow.tsx @@ -0,0 +1,196 @@ +import { gatewayPrimitive, knowledgeBasePrimitive } from '../../../primitives/registry'; +import { ErrorPrompt } from '../../components'; +import { useExistingGateways } from '../../hooks/useCreateMcp'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddKnowledgeBaseScreen } from './AddKnowledgeBaseScreen'; +import { groupDataSources } from './groupDataSources'; +import { isInlineJsonValue, materializeInlineConnectorConfig, stripInlineJsonPrefix } from './inline-connector-config'; +import type { AddKnowledgeBaseConfig, CapturedDataSource } from './types'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'create-wizard' } + | { name: 'create-success'; knowledgeBaseName: string; sources: string[]; gatewayWired?: string } + | { name: 'error'; message: string }; + +interface AddKnowledgeBaseFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function AddKnowledgeBaseFlow({ + isInteractive = true, + onExit, + onBack, + onDev, + onDeploy, +}: AddKnowledgeBaseFlowProps) { + const [flow, setFlow] = useState({ name: 'create-wizard' }); + const [existingNames, setExistingNames] = useState([]); + const { gateways: existingGateways } = useExistingGateways(); + + // Load existing KB names for duplicate detection. + useEffect(() => { + void knowledgeBasePrimitive.getRemovable().then(removables => { + setExistingNames(removables.map(r => r.name)); + }); + }, []); + + // In non-interactive mode, exit after success. + useEffect(() => { + if (!isInteractive && flow.name === 'create-success') { + onExit(); + } + }, [isInteractive, flow.name, onExit]); + + const handleComplete = useCallback((config: AddKnowledgeBaseConfig) => { + void (async () => { + // Materialize any inline-JSON connector configs to disk first. The + // wizard tags those entries with INLINE_JSON_PREFIX; we strip the + // prefix, write the JSON to app//.json, and replace + // the captured value with the resulting path so the primitive sees a + // normal connector-config path. Failures here surface to the user as a + // wizard error before any primitive call. + let materializedSources: CapturedDataSource[]; + try { + materializedSources = await Promise.all( + config.dataSources.map(async ds => { + if (!isInlineJsonValue(ds.value)) return ds; + const json = stripInlineJsonPrefix(ds.value); + const path = await materializeInlineConnectorConfig({ + kbName: config.name, + dataSourceType: ds.dataSourceType, + jsonContents: json, + }); + return { dataSourceType: ds.dataSourceType, value: path }; + }) + ); + } catch (err) { + setFlow({ + name: 'error', + message: `Failed to save inline connector config: ${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + + // Group captured sources by data-source-type, then dispatch one + // primitive.add() per group sequentially: the first call creates the + // KB, subsequent calls hit appendToExisting() and add their sources to + // the same KB. The primitive's gateway-equality guard accepts the same + // gateway value on every append; description is sent only on the first + // call so the no-update guard can't trip. + const groups = groupDataSources(materializedSources); + if (groups.length === 0) { + setFlow({ name: 'error', message: 'No data sources captured.' }); + return; + } + + // If the user chose "Create a new gateway and attach", create the + // gateway BEFORE the KB add. Use sensible defaults — authorizer NONE, + // semantic search on — so the inline-create stays a single step. The + // user can edit the gateway later via `agentcore add gateway` flags or + // the schema directly. Mutually exclusive with `config.gateway`. + // + // Track whether we created the gateway in *this* flow so we can roll it + // back if a downstream KB add fails. Without this, a failure mid-flow + // (duplicate source, gateway-equality mismatch, etc.) leaves the new + // gateway persisted in agentcore.json with no KB attached — the user + // sees an error toast but their config has drifted. + let gatewayToWire: string | undefined = config.gateway; + let createdGatewayInThisFlow: string | undefined; + if (config.newGatewayName) { + const gwResult = await gatewayPrimitive.add({ + name: config.newGatewayName, + authorizerType: 'NONE', + enableSemanticSearch: true, + }); + if (!gwResult.success) { + setFlow({ + name: 'error', + message: `Failed to create gateway "${config.newGatewayName}": ${gwResult.error.message}`, + }); + return; + } + gatewayToWire = gwResult.gatewayName; + createdGatewayInThisFlow = gwResult.gatewayName; + } + + const rollbackGatewayIfCreated = async (reason: string): Promise => { + if (!createdGatewayInThisFlow) return reason; + const removeResult = await gatewayPrimitive.remove(createdGatewayInThisFlow); + if (removeResult.success) { + return `${reason} (rolled back the gateway "${createdGatewayInThisFlow}" that was just created.)`; + } + return `${reason} (note: gateway "${createdGatewayInThisFlow}" was created but rollback failed: ${removeResult.error?.message ?? 'unknown error'}. Run \`agentcore remove gateway --name ${createdGatewayInThisFlow}\` to clean up.)`; + }; + + const totalSources: string[] = []; + let gatewayWired: string | undefined; + + for (let i = 0; i < groups.length; i++) { + const group = groups[i]!; + const isS3 = group.dataSourceType === 's3'; + const isFirst = i === 0; + const result = await knowledgeBasePrimitive.add({ + name: config.name, + ...(isFirst && config.description ? { description: config.description } : {}), + dataSourceType: group.dataSourceType, + ...(isS3 ? { source: group.values } : { connectorConfig: group.values }), + gateway: gatewayToWire, + }); + + if (!result.success) { + const message = await rollbackGatewayIfCreated( + `Failed on ${group.dataSourceType} group: ${result.error.message}` + ); + setFlow({ name: 'error', message }); + return; + } + + totalSources.push(...result.newDataSources); + if (result.gatewayWired) { + gatewayWired = result.gatewayWired; + } + } + + setFlow({ + name: 'create-success', + knowledgeBaseName: config.name, + sources: totalSources, + gatewayWired, + }); + })(); + }, []); + + if (flow.name === 'create-wizard') { + return ( + + ); + } + if (flow.name === 'create-success') { + const wiredSuffix = flow.gatewayWired ? ` Wired to gateway "${flow.gatewayWired}" as a connector target.` : ''; + return ( + + ); + } + if (flow.name === 'error') { + return ; + } + return null; +} diff --git a/src/cli/tui/screens/knowledge-base/AddKnowledgeBaseScreen.tsx b/src/cli/tui/screens/knowledge-base/AddKnowledgeBaseScreen.tsx new file mode 100644 index 000000000..14596c8f1 --- /dev/null +++ b/src/cli/tui/screens/knowledge-base/AddKnowledgeBaseScreen.tsx @@ -0,0 +1,506 @@ +import { GatewayNameSchema, KnowledgeBaseNameSchema, S3DataSourceSchema } from '../../../../schema'; +import { type DataSourceTypeFlag, flagToWireType } from '../../../operations/knowledge-base/connector-config'; +import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { generateUniqueName } from '../../utils'; +import { INLINE_JSON_PREFIX } from './inline-connector-config'; +import type { AddKnowledgeBaseConfig, CapturedDataSource } from './types'; +import React, { useMemo, useState } from 'react'; +import { z } from 'zod'; + +// Canonical step list. The 'new-gateway-name' state is intentionally a +// sub-step of 'gateway' (mirrors the kb-id sub-step pattern in +// useAddGatewayTargetWizard) and is not in this list — it maps onto 'gateway' +// for the StepIndicator/index lookup. +type Step = 'name' | 'description' | 'data-source-type' | 'sources' | 'add-another' | 'gateway' | 'confirm'; +// 'remove-source' is a sub-step of 'add-another' — it shows a picker of +// captured sources so the user can drop one before continuing. Mapped onto +// 'add-another' for the StepIndicator. Same pattern as 'new-gateway-name'. +type WizardState = Step | 'new-gateway-name' | 'remove-source'; + +const STEP_LABELS: Record = { + name: 'Name', + description: 'Description', + 'data-source-type': 'Source Type', + sources: 'Sources', + 'add-another': 'Add another?', + gateway: 'Gateway', + confirm: 'Confirm', +}; + +const STEPS: Step[] = ['name', 'description', 'data-source-type', 'sources', 'add-another', 'gateway', 'confirm']; + +// Each source carries its own type, so a single wizard run can mix S3 with one +// or more connector types. The Flow groups by `dataSourceType` and dispatches +// one primitive.add() call per group: the first creates the KB, subsequent +// groups append to it. +const DATA_SOURCE_TYPE_OPTIONS: SelectableItem[] = [ + { id: 's3', title: 'Amazon S3 — documents in an S3 bucket' }, + { id: 'web-crawler', title: 'Web Crawler — crawl and index web pages' }, + { id: 'confluence', title: 'Confluence — Atlassian Confluence wiki' }, + { id: 'sharepoint', title: 'SharePoint — Microsoft SharePoint documents' }, + { id: 'onedrive', title: 'OneDrive — Microsoft OneDrive files' }, + { id: 'google-drive', title: 'Google Drive — Google Drive files' }, +]; + +// Friendly label for each data-source-type id, used in the confirm view. +const DATA_SOURCE_TYPE_LABELS: Record = { + s3: 'S3', + 'web-crawler': 'Web Crawler', + confluence: 'Confluence', + sharepoint: 'SharePoint', + onedrive: 'OneDrive', + 'google-drive': 'Google Drive', +}; + +const ADD_ANOTHER_OPTIONS: SelectableItem[] = [ + { id: 'add-another', title: 'Add another data source' }, + { id: 'done', title: 'Done — review and submit' }, +]; + +// Same shape, augmented with a "Remove a captured source" option that we +// surface only when the user already has a source they could drop. +const ADD_ANOTHER_OPTIONS_WITH_REMOVE: SelectableItem[] = [ + { id: 'add-another', title: 'Add another data source' }, + { id: 'remove-source', title: 'Remove a captured data source' }, + { id: 'done', title: 'Done — review and submit' }, +]; + +// Connector-config inputs accept EITHER a file path OR the JSON contents +// pasted in directly. Most terminals collapse a pasted multi-line JSON into a +// single line of text — that's fine, JSON.parse doesn't care about newlines. +// +// We classify by the first non-whitespace character: `{` means inline JSON, +// anything else means a file path. Inline JSON is parsed inline so the user +// gets immediate feedback if it's malformed or its `type` field doesn't match +// the connector kind they picked at the data-source-type step. The Flow +// materializes accepted inline JSON to a file under app// before +// dispatching to the primitive. +// +// Path inputs only get a non-empty check here; the file's actual contents are +// validated in the primitive's add() (file exists, JSON parses, type matches). +function makeConnectorConfigSchema(pendingType: string) { + const declaredWireType = flagToWireType(pendingType); + return z + .string() + .min(1, 'Enter a connector config file path or paste the JSON contents') + .superRefine((s, ctx) => { + const trimmed = s.trimStart(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + // Treat as a file path; primitive validates the rest. + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Looks like JSON but failed to parse. Check brackets and quoting.', + }); + return; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Connector config must be a JSON object (e.g. { "type": "WEB", ... }).', + }); + return; + } + const obj = parsed as Record; + if (typeof obj.type !== 'string') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Connector config is missing a string "type" field.', + }); + return; + } + if (obj.type !== declaredWireType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Connector config "type" is "${obj.type}" but you picked ${pendingType} (expects "${declaredWireType}").`, + }); + } + }); +} + +const SKIP_GATEWAY_ID = '__skip__'; +const CREATE_NEW_GATEWAY_ID = '__create_new__'; + +// Extract just the URI piece of S3DataSourceSchema for inline validation in +// the TextInput component. +const S3UriSchema = z + .string() + .min(1) + .refine(uri => S3DataSourceSchema.safeParse({ type: 'S3', uri }).success, { + message: 'Must be a valid s3://bucket[/prefix] URI', + }); + +interface AddKnowledgeBaseScreenProps { + onComplete: (config: AddKnowledgeBaseConfig) => void; + onExit: () => void; + existingKnowledgeBaseNames: string[]; + existingGatewayNames: string[]; +} + +export function AddKnowledgeBaseScreen({ + onComplete, + onExit, + existingKnowledgeBaseNames, + existingGatewayNames, +}: AddKnowledgeBaseScreenProps) { + const [step, setStep] = useState('name'); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + // The type currently being entered at the 'sources' step. Updated every + // time the user passes through 'data-source-type' (including the loop from + // 'add-another -> yes'), so each captured source is tagged with the type + // active at the moment it was entered. + const [pendingType, setPendingType] = useState('s3'); + const [dataSources, setDataSources] = useState([]); + const [gateway, setGateway] = useState(undefined); + // When the user chose "Create a new gateway and attach", this holds the + // typed name. The KB Flow consumes this and creates the gateway first + // before adding the KB. Mutually exclusive with `gateway`. + const [newGatewayName, setNewGatewayName] = useState(undefined); + + const isPendingS3 = pendingType === 's3'; + + const isNameStep = step === 'name'; + const isDescriptionStep = step === 'description'; + const isDataSourceTypeStep = step === 'data-source-type'; + const isSourcesStep = step === 'sources'; + const isAddAnotherStep = step === 'add-another'; + const isRemoveSourceStep = step === 'remove-source'; + const isGatewayStep = step === 'gateway'; + const isNewGatewayNameStep = step === 'new-gateway-name'; + const isConfirmStep = step === 'confirm'; + + const hasGateways = existingGatewayNames.length > 0; + + // Number of sources already captured for the *current* pendingType run, used + // to label inputs ("S3 URI #2") and decide where Esc returns to. + const sourcesForPendingType = useMemo( + () => dataSources.filter(ds => ds.dataSourceType === pendingType).length, + [dataSources, pendingType] + ); + + // Gateway-step picker contents adapt to whether any gateways exist: + // - Zero gateways: ["Create a new gateway and attach", "Skip — KB will be standalone"]. + // - One or more gateways: existing names + "Skip" sentinel + "Create a new gateway and attach" + // appended at the end. + const gatewayItems: SelectableItem[] = useMemo(() => { + if (!hasGateways) { + return [ + { id: CREATE_NEW_GATEWAY_ID, title: 'Create a new gateway and attach' }, + { id: SKIP_GATEWAY_ID, title: 'Skip — KB will be standalone (you can attach later)' }, + ]; + } + return [ + ...existingGatewayNames.map(g => ({ id: g, title: g })), + { id: SKIP_GATEWAY_ID, title: 'Skip — don’t wire to a gateway' }, + { id: CREATE_NEW_GATEWAY_ID, title: 'Create a new gateway and attach' }, + ]; + }, [existingGatewayNames, hasGateways]); + + const dataSourceTypeNav = useListNavigation({ + items: DATA_SOURCE_TYPE_OPTIONS, + isActive: isDataSourceTypeStep, + onSelect: (item: SelectableItem) => { + setPendingType(item.id as DataSourceTypeFlag); + setStep('sources'); + }, + // Esc from the type picker: if we already have at least one captured + // source, the only sensible return is the add-another decision (we can't + // un-capture earlier sources). Otherwise go back to description. + onExit: () => setStep(dataSources.length === 0 ? 'description' : 'add-another'), + }); + + // Surface the "Remove a captured source" option only when there's something + // to remove. Avoids showing a dead-end action when the user has just one + // source and would have to cancel the wizard if they picked it (you can't + // submit a KB with zero sources). + const addAnotherItems = dataSources.length > 1 ? ADD_ANOTHER_OPTIONS_WITH_REMOVE : ADD_ANOTHER_OPTIONS; + const addAnotherNav = useListNavigation({ + items: addAnotherItems, + isActive: isAddAnotherStep, + onSelect: (item: SelectableItem) => { + if (item.id === 'add-another') { + // FIX: route back through the data-source-type picker so the user can + // pick a different type (or the same one) for the next source. + setStep('data-source-type'); + } else if (item.id === 'remove-source') { + setStep('remove-source'); + } else { + setStep('gateway'); + } + }, + onExit: () => setStep('data-source-type'), + }); + + // Captured-source picker (sub-step of 'add-another'). Each item shows the + // type and the value (or an inline-JSON marker), keyed by capture index so + // duplicates pick different rows. + const removableSourceItems = useMemo( + () => + dataSources.map((ds, idx) => { + const label = DATA_SOURCE_TYPE_LABELS[ds.dataSourceType] ?? ds.dataSourceType; + const display = ds.value.startsWith(INLINE_JSON_PREFIX) ? '' : ds.value; + return { id: String(idx), title: `${label}: ${display}` }; + }), + [dataSources] + ); + + const removeSourceNav = useListNavigation({ + items: removableSourceItems, + isActive: isRemoveSourceStep, + onSelect: (item: SelectableItem) => { + const idx = Number(item.id); + const next = dataSources.filter((_, i) => i !== idx); + setDataSources(next); + // Stay on add-another if there's still anything left, otherwise drop + // straight back to data-source-type so the user can capture again. + setStep(next.length > 0 ? 'add-another' : 'data-source-type'); + }, + onExit: () => setStep('add-another'), + }); + + const gatewayNav = useListNavigation({ + items: gatewayItems, + isActive: isGatewayStep, + onSelect: (item: SelectableItem) => { + if (item.id === CREATE_NEW_GATEWAY_ID) { + // Sub-step: prompt for a new gateway name. Don't create it yet — the + // Flow does the create + KB add as a single submit so the user only + // sees the gateway materialise after confirming. + setGateway(undefined); + setStep('new-gateway-name'); + return; + } + setNewGatewayName(undefined); + setGateway(item.id === SKIP_GATEWAY_ID ? undefined : item.id); + setStep('confirm'); + }, + onExit: () => setStep('add-another'), + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => + onComplete({ + name, + dataSources, + description: description || undefined, + gateway, + newGatewayName, + }), + onExit: () => setStep('gateway'), + isActive: isConfirmStep, + }); + + const helpText = + isDataSourceTypeStep || isAddAnotherStep || isGatewayStep || isRemoveSourceStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; + + // The new-gateway-name and remove-source sub-steps map onto their parents + // for the StepIndicator, mirroring the kb-id sub-step pattern in + // useAddGatewayTargetWizard. + const indicatorStep: Step = isNewGatewayNameStep ? 'gateway' : isRemoveSourceStep ? 'add-another' : step; + const headerContent = ; + + // Confirm view: render every captured source on its own line, prefixed by + // its type label (e.g. "S3: s3://bucket/docs/"). Lines stay in + // capture order so the user sees exactly what they entered. + const dataSourcesSummary = useMemo(() => { + if (dataSources.length === 0) return '(none)'; + const labelWidth = + Math.max(...dataSources.map(ds => (DATA_SOURCE_TYPE_LABELS[ds.dataSourceType] ?? ds.dataSourceType).length)) + 1; + return dataSources + .map(ds => { + const label = `${DATA_SOURCE_TYPE_LABELS[ds.dataSourceType] ?? ds.dataSourceType}:`.padEnd(labelWidth + 1); + // Inline-JSON entries carry the full payload as their `value`; render + // a short summary instead of dumping the JSON into the confirm card. + const display = ds.value.startsWith(INLINE_JSON_PREFIX) + ? `'}/>` + : ds.value; + return `${label} ${display}`; + }) + .join('\n'); + }, [dataSources, name]); + + const gatewayConfirmValue = useMemo(() => { + if (newGatewayName) return `${newGatewayName} (will be created)`; + if (gateway) return `${gateway} (existing)`; + return 'none — KB will be standalone'; + }, [gateway, newGatewayName]); + + const confirmFields = useMemo( + () => [ + { label: 'Name', value: name }, + ...(description ? [{ label: 'Description', value: description }] : []), + { label: `Data Sources (${dataSources.length})`, value: dataSourcesSummary }, + { label: 'Gateway', value: gatewayConfirmValue }, + ], + [name, description, dataSources.length, dataSourcesSummary, gatewayConfirmValue] + ); + + return ( + + + {isNameStep && ( + { + setName(value); + setStep('description'); + }} + onCancel={onExit} + schema={KnowledgeBaseNameSchema} + customValidation={value => + !existingKnowledgeBaseNames.includes(value) || 'Knowledge base name already exists' + } + /> + )} + + {isDescriptionStep && ( + { + setDescription(value); + setStep('data-source-type'); + }} + onCancel={() => setStep('name')} + allowEmpty + /> + )} + + {isDataSourceTypeStep && ( + + )} + + {isSourcesStep && isPendingS3 && ( + { + setDataSources([...dataSources, { dataSourceType: pendingType, value }]); + setStep('add-another'); + }} + onCancel={() => setStep(dataSources.length === 0 ? 'data-source-type' : 'add-another')} + schema={S3UriSchema} + /> + )} + + {isSourcesStep && !isPendingS3 && ( + { + const trimmed = value.trimStart(); + const isInlineJson = trimmed.startsWith('{') || trimmed.startsWith('['); + setDataSources([ + ...dataSources, + { + dataSourceType: pendingType, + // Tag inline JSON with a sentinel prefix; the Flow writes it + // to disk before dispatching to the primitive. Plain paths + // pass through unchanged (the primitive does its own copy). + value: isInlineJson ? `${INLINE_JSON_PREFIX}${trimmed}` : value, + }, + ]); + setStep('add-another'); + }} + onCancel={() => setStep(dataSources.length === 0 ? 'data-source-type' : 'add-another')} + schema={makeConnectorConfigSchema(pendingType)} + /> + )} + + {isAddAnotherStep && ( + ds.dataSourceType)).size + } type(s)`} + items={addAnotherItems} + selectedIndex={addAnotherNav.selectedIndex} + /> + )} + + {isRemoveSourceStep && ( + + )} + + {isGatewayStep && ( + + )} + + {isNewGatewayNameStep && ( + { + setNewGatewayName(value); + setGateway(undefined); + setStep('confirm'); + }} + onCancel={() => setStep('gateway')} + schema={GatewayNameSchema} + customValidation={value => + !existingGatewayNames.includes(value) || 'Gateway name already exists in this project' + } + /> + )} + + {isConfirmStep && } + + + ); +} diff --git a/src/cli/tui/screens/knowledge-base/__tests__/AddKnowledgeBaseFlow.test.tsx b/src/cli/tui/screens/knowledge-base/__tests__/AddKnowledgeBaseFlow.test.tsx new file mode 100644 index 000000000..12c9c01e9 --- /dev/null +++ b/src/cli/tui/screens/knowledge-base/__tests__/AddKnowledgeBaseFlow.test.tsx @@ -0,0 +1,134 @@ +import { AddKnowledgeBaseFlow } from '../AddKnowledgeBaseFlow'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ─── mocks ─────────────────────────────────────────────────────────────────── +const mockKbAdd = vi.fn(); +const mockKbGetRemovable = vi.fn(); +const mockGatewayAdd = vi.fn(); + +vi.mock('../../../../primitives/registry', () => ({ + knowledgeBasePrimitive: { + add: (...args: unknown[]) => mockKbAdd(...args), + getRemovable: (...args: unknown[]) => mockKbGetRemovable(...args), + }, + gatewayPrimitive: { + add: (...args: unknown[]) => mockGatewayAdd(...args), + }, +})); + +vi.mock('../../../hooks/useCreateMcp', () => ({ + useExistingGateways: () => ({ gateways: [] }), +})); + +// Replace the screen with a stub that immediately invokes onComplete with a +// fixed config — keeps Flow tests focused on the post-screen logic. +vi.mock('../AddKnowledgeBaseScreen', () => { + return { + AddKnowledgeBaseScreen: ({ onComplete }: { onComplete: (cfg: unknown) => void }) => { + // Immediately submit on first render. Tests below customise the payload + // by setting a global before the render. + const cfg = (globalThis as { __KB_FLOW_TEST_CFG?: unknown }).__KB_FLOW_TEST_CFG; + React.useEffect(() => { + if (cfg) onComplete(cfg); + }, [onComplete, cfg]); + return null; + }, + }; +}); + +// ─── helpers ───────────────────────────────────────────────────────────────── +const delay = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); + +beforeEach(() => { + mockKbAdd.mockReset(); + mockKbGetRemovable.mockReset(); + mockGatewayAdd.mockReset(); + mockKbGetRemovable.mockResolvedValue([]); + mockKbAdd.mockResolvedValue({ + success: true, + knowledgeBaseName: 'kb1', + newDataSources: ['s3-1'], + gatewayWired: undefined, + }); + mockGatewayAdd.mockResolvedValue({ success: true, gatewayName: 'tui-kb-gw' }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + delete (globalThis as { __KB_FLOW_TEST_CFG?: unknown }).__KB_FLOW_TEST_CFG; +}); + +// ─── tests ─────────────────────────────────────────────────────────────────── +describe('AddKnowledgeBaseFlow — newGatewayName path', () => { + it('creates the gateway first, then adds the KB with that gateway name', async () => { + (globalThis as { __KB_FLOW_TEST_CFG?: unknown }).__KB_FLOW_TEST_CFG = { + name: 'tui-kb', + dataSources: [{ dataSourceType: 's3', value: 's3://b/' }], + newGatewayName: 'tui-kb-gw', + }; + mockKbAdd.mockResolvedValueOnce({ success: true, newDataSources: ['s3-1'], gatewayWired: 'tui-kb-gw' }); + + render(); + await delay(80); + + expect(mockGatewayAdd).toHaveBeenCalledTimes(1); + expect(mockGatewayAdd.mock.calls[0]![0]).toMatchObject({ name: 'tui-kb-gw', authorizerType: 'NONE' }); + expect(mockKbAdd).toHaveBeenCalledTimes(1); + expect(mockKbAdd.mock.calls[0]![0]).toMatchObject({ name: 'tui-kb', gateway: 'tui-kb-gw' }); + }); + + it('aborts (no KB add) if the gateway create fails', async () => { + (globalThis as { __KB_FLOW_TEST_CFG?: unknown }).__KB_FLOW_TEST_CFG = { + name: 'tui-kb', + dataSources: [{ dataSourceType: 's3', value: 's3://b/' }], + newGatewayName: 'tui-kb-gw', + }; + mockGatewayAdd.mockResolvedValueOnce({ success: false, error: new Error('boom') }); + + const { lastFrame } = render(); + await delay(80); + + expect(mockGatewayAdd).toHaveBeenCalledTimes(1); + expect(mockKbAdd).not.toHaveBeenCalled(); + expect(lastFrame() ?? '').toContain('Failed'); + }); +}); + +describe('AddKnowledgeBaseFlow — Skip / standalone path (zero gateways case)', () => { + it('does not call gatewayPrimitive.add and adds the KB with no gateway', async () => { + (globalThis as { __KB_FLOW_TEST_CFG?: unknown }).__KB_FLOW_TEST_CFG = { + name: 'standalone-kb', + dataSources: [{ dataSourceType: 's3', value: 's3://b/' }], + // No gateway, no newGatewayName + }; + mockKbAdd.mockResolvedValueOnce({ success: true, newDataSources: ['s3-1'], gatewayWired: undefined }); + + render(); + await delay(80); + + expect(mockGatewayAdd).not.toHaveBeenCalled(); + expect(mockKbAdd).toHaveBeenCalledTimes(1); + expect(mockKbAdd.mock.calls[0]![0]).toMatchObject({ name: 'standalone-kb' }); + expect(mockKbAdd.mock.calls[0]![0].gateway).toBeUndefined(); + }); +}); + +describe('AddKnowledgeBaseFlow — existing gateway path', () => { + it('passes the existing gateway through to KB add and skips gateway create', async () => { + (globalThis as { __KB_FLOW_TEST_CFG?: unknown }).__KB_FLOW_TEST_CFG = { + name: 'kb-existing', + dataSources: [{ dataSourceType: 's3', value: 's3://b/' }], + gateway: 'g1', + }; + mockKbAdd.mockResolvedValueOnce({ success: true, newDataSources: ['s3-1'], gatewayWired: 'g1' }); + + render(); + await delay(80); + + expect(mockGatewayAdd).not.toHaveBeenCalled(); + expect(mockKbAdd).toHaveBeenCalledTimes(1); + expect(mockKbAdd.mock.calls[0]![0]).toMatchObject({ name: 'kb-existing', gateway: 'g1' }); + }); +}); diff --git a/src/cli/tui/screens/knowledge-base/__tests__/AddKnowledgeBaseScreen.test.tsx b/src/cli/tui/screens/knowledge-base/__tests__/AddKnowledgeBaseScreen.test.tsx new file mode 100644 index 000000000..787f9623f --- /dev/null +++ b/src/cli/tui/screens/knowledge-base/__tests__/AddKnowledgeBaseScreen.test.tsx @@ -0,0 +1,238 @@ +import { AddKnowledgeBaseScreen } from '../AddKnowledgeBaseScreen'; +import type { AddKnowledgeBaseConfig } from '../types'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const DOWN_ARROW = '\x1B[B'; +const UP_ARROW = '\x1B[A'; +const ENTER = '\r'; +const ESCAPE = '\x1B'; +const BACKSPACE = '\x7f'; +const delay = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); + +const BASE_PROPS = { + onComplete: vi.fn<(config: AddKnowledgeBaseConfig) => void>(), + onExit: vi.fn(), + existingKnowledgeBaseNames: [], + existingGatewayNames: [], +}; + +afterEach(() => vi.restoreAllMocks()); + +// Helper: walk through name → description → s3 → one URI → done. +// Stops on the gateway-step picker. +async function walkToGatewayStep(stdin: ReturnType['stdin'], kbName = 'tui-kb') { + // Name step: clear default and type custom name. + for (let i = 0; i < 30; i++) stdin.write(BACKSPACE); + for (const ch of kbName) stdin.write(ch); + await delay(); + stdin.write(ENTER); + await delay(); + + // Description: skip + stdin.write(ENTER); + await delay(); + + // Data-source-type: S3 is index 0, accept + stdin.write(ENTER); + await delay(); + + // Sources: type a URI + for (const ch of 's3://my-bucket/docs/') stdin.write(ch); + await delay(); + stdin.write(ENTER); + await delay(); + + // Add another? Move down to "Done — review and submit" + stdin.write(DOWN_ARROW); + await delay(); + stdin.write(ENTER); + await delay(); +} + +describe('AddKnowledgeBaseScreen — gateway step always shown', () => { + it('zero gateways: gateway step shows Create-new + Skip (no other items)', async () => { + const { lastFrame, stdin } = render(); + await walkToGatewayStep(stdin); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('Wire this knowledge base to a gateway?'); + expect(frame).toContain('Create a new gateway and attach'); + expect(frame).toContain('Skip — KB will be standalone'); + expect(frame).toContain('No gateways exist in this project yet'); + }); + + it('zero gateways: picking Skip goes to confirm with "Gateway: none — KB will be standalone"', async () => { + const { lastFrame, stdin } = render(); + await walkToGatewayStep(stdin); + + // Move from "Create new" (index 0) down to "Skip" (index 1) + stdin.write(DOWN_ARROW); + await delay(); + stdin.write(ENTER); + await delay(); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('Gateway:'); + expect(frame).toContain('none — KB will be standalone'); + }); + + it('zero gateways: picking Create-new advances to a name input defaulted to "${kbName}-gw"', async () => { + const { lastFrame, stdin } = render(); + await walkToGatewayStep(stdin, 'mykb'); + + // "Create a new gateway and attach" is index 0 in the zero-gateway picker + stdin.write(ENTER); + await delay(); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('New gateway name'); + expect(frame).toContain('mykb-gw'); + }); + + it('zero gateways: full flow Create-new → submit emits newGatewayName, no gateway', async () => { + const onComplete = vi.fn<(config: AddKnowledgeBaseConfig) => void>(); + const { stdin } = render(); + await walkToGatewayStep(stdin, 'tui-kb'); + + // Pick Create-new (index 0) + stdin.write(ENTER); + await delay(); + // Accept default name "tui-kb-gw" + stdin.write(ENTER); + await delay(); + // Confirm + stdin.write(ENTER); + await delay(); + + expect(onComplete).toHaveBeenCalledTimes(1); + const cfg = onComplete.mock.calls[0]![0]; + expect(cfg.newGatewayName).toBe('tui-kb-gw'); + expect(cfg.gateway).toBeUndefined(); + expect(cfg.name).toBe('tui-kb'); + }); + + it('zero gateways: confirm view shows "Gateway: tui-kb-gw (will be created)"', async () => { + const { lastFrame, stdin } = render(); + await walkToGatewayStep(stdin, 'tui-kb'); + + stdin.write(ENTER); // Create-new + await delay(); + stdin.write(ENTER); // accept default + await delay(); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('tui-kb-gw (will be created)'); + }); + + it('rejects invalid gateway names with the schema error', async () => { + const { lastFrame, stdin } = render(); + await walkToGatewayStep(stdin, 'tui-kb'); + + stdin.write(ENTER); // Create-new + await delay(); + // Clear default and type invalid name + for (let i = 0; i < 30; i++) stdin.write(BACKSPACE); + for (const ch of 'bad name!') stdin.write(ch); + await delay(); + stdin.write(ENTER); + await delay(); + + const frame = lastFrame() ?? ''; + expect(frame).toMatch(/alphanumeric with optional hyphens|invalid|error/i); + }); +}); + +describe('AddKnowledgeBaseScreen — at-least-one-gateway path', () => { + const PROPS_WITH_GW = { ...BASE_PROPS, existingGatewayNames: ['g1', 'g2'] }; + + it('shows existing names + Skip + Create-new in that order', async () => { + const { lastFrame, stdin } = render(); + await walkToGatewayStep(stdin); + const frame = lastFrame() ?? ''; + expect(frame).toContain('g1'); + expect(frame).toContain('g2'); + expect(frame).toContain('Skip'); + expect(frame).toContain('Create a new gateway and attach'); + }); + + it('selecting an existing gateway emits gateway=g1, no newGatewayName', async () => { + const onComplete = vi.fn<(config: AddKnowledgeBaseConfig) => void>(); + const { stdin } = render(); + await walkToGatewayStep(stdin); + + // g1 is index 0 + stdin.write(ENTER); + await delay(); + // Confirm + stdin.write(ENTER); + await delay(); + + expect(onComplete).toHaveBeenCalledTimes(1); + const cfg = onComplete.mock.calls[0]![0]; + expect(cfg.gateway).toBe('g1'); + expect(cfg.newGatewayName).toBeUndefined(); + }); + + it('Create-new appended at end is reachable; choosing it advances to the name input', async () => { + const { lastFrame, stdin } = render(); + await walkToGatewayStep(stdin, 'kb'); + // Items: g1(0), g2(1), Skip(2), Create-new(3). Down 3 times. + stdin.write(DOWN_ARROW); + await delay(); + stdin.write(DOWN_ARROW); + await delay(); + stdin.write(DOWN_ARROW); + await delay(); + stdin.write(ENTER); + await delay(); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('New gateway name'); + expect(frame).toContain('kb-gw'); + }); + + it('confirm shows "Gateway: g2 (existing)" when an existing gateway picked', async () => { + const { lastFrame, stdin } = render(); + await walkToGatewayStep(stdin); + + // Move to g2 + stdin.write(DOWN_ARROW); + await delay(); + stdin.write(ENTER); + await delay(); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('g2 (existing)'); + }); + + it('Esc from new-gateway-name returns to the gateway picker', async () => { + const { lastFrame, stdin } = render(); + await walkToGatewayStep(stdin); + + // Pick Create-new (index 3) + for (let i = 0; i < 3; i++) { + stdin.write(DOWN_ARROW); + await delay(); + } + stdin.write(ENTER); + await delay(); + // Should be on new-gateway-name. Esc back. + stdin.write(ESCAPE); + await delay(); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('Wire this knowledge base to a gateway?'); + }); +}); + +describe('AddKnowledgeBaseScreen — step indicator', () => { + it('always shows the Gateway label in the step list, even with zero gateways', async () => { + const { lastFrame } = render(); + await delay(); + expect(lastFrame() ?? '').toContain('Gateway'); + }); +}); +// Suppress unused imports from helper — keep references silent +void UP_ARROW; diff --git a/src/cli/tui/screens/knowledge-base/__tests__/groupDataSources.test.ts b/src/cli/tui/screens/knowledge-base/__tests__/groupDataSources.test.ts new file mode 100644 index 000000000..a8a9e01f5 --- /dev/null +++ b/src/cli/tui/screens/knowledge-base/__tests__/groupDataSources.test.ts @@ -0,0 +1,49 @@ +import { groupDataSources } from '../groupDataSources'; +import { describe, expect, it } from 'vitest'; + +describe('groupDataSources', () => { + it('returns empty array for empty input', () => { + expect(groupDataSources([])).toEqual([]); + }); + + it('groups a single-type list into one group', () => { + const result = groupDataSources([ + { dataSourceType: 's3', value: 's3://a/' }, + { dataSourceType: 's3', value: 's3://b/' }, + ]); + expect(result).toEqual([{ dataSourceType: 's3', values: ['s3://a/', 's3://b/'] }]); + }); + + it('groups by type and preserves first-seen-type ordering across groups', () => { + const result = groupDataSources([ + { dataSourceType: 's3', value: 's3://a/' }, + { dataSourceType: 'web-crawler', value: 'app/k/web.json' }, + { dataSourceType: 's3', value: 's3://b/' }, + { dataSourceType: 'confluence', value: 'app/k/conf.json' }, + { dataSourceType: 'web-crawler', value: 'app/k/web2.json' }, + ]); + expect(result).toEqual([ + { dataSourceType: 's3', values: ['s3://a/', 's3://b/'] }, + { dataSourceType: 'web-crawler', values: ['app/k/web.json', 'app/k/web2.json'] }, + { dataSourceType: 'confluence', values: ['app/k/conf.json'] }, + ]); + }); + + it('preserves insertion order within a group', () => { + const result = groupDataSources([ + { dataSourceType: 'web-crawler', value: 'first.json' }, + { dataSourceType: 'web-crawler', value: 'second.json' }, + { dataSourceType: 'web-crawler', value: 'third.json' }, + ]); + expect(result[0]!.values).toEqual(['first.json', 'second.json', 'third.json']); + }); + + it('handles a single source per type with no inter-group reordering', () => { + const result = groupDataSources([ + { dataSourceType: 'confluence', value: 'a' }, + { dataSourceType: 's3', value: 's3://b/' }, + { dataSourceType: 'web-crawler', value: 'c' }, + ]); + expect(result.map(g => g.dataSourceType)).toEqual(['confluence', 's3', 'web-crawler']); + }); +}); diff --git a/src/cli/tui/screens/knowledge-base/__tests__/inline-connector-config.test.ts b/src/cli/tui/screens/knowledge-base/__tests__/inline-connector-config.test.ts new file mode 100644 index 000000000..df9001d38 --- /dev/null +++ b/src/cli/tui/screens/knowledge-base/__tests__/inline-connector-config.test.ts @@ -0,0 +1,97 @@ +import { ConfigIO } from '../../../../../lib'; +import { + INLINE_JSON_PREFIX, + isInlineJsonValue, + materializeInlineConnectorConfig, + stripInlineJsonPrefix, +} from '../inline-connector-config'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +describe('inline-connector-config — sentinel helpers', () => { + it('isInlineJsonValue / stripInlineJsonPrefix round-trip', () => { + const json = '{"type":"WEB"}'; + const tagged = `${INLINE_JSON_PREFIX}${json}`; + expect(isInlineJsonValue(tagged)).toBe(true); + expect(isInlineJsonValue('app/kb/web.json')).toBe(false); + expect(stripInlineJsonPrefix(tagged)).toBe(json); + expect(stripInlineJsonPrefix('app/kb/web.json')).toBe('app/kb/web.json'); + }); +}); + +describe('materializeInlineConnectorConfig', () => { + let projectRoot: string; + let configIO: ConfigIO; + + beforeEach(() => { + // Build a minimal project tree so ConfigIO discovers the agentcore/ dir. + projectRoot = mkdtempSync(join(tmpdir(), 'fmkb-inline-')); + mkdirSync(join(projectRoot, 'agentcore'), { recursive: true }); + writeFileSync( + join(projectRoot, 'agentcore', 'agentcore.json'), + JSON.stringify({ name: 'p', version: 1, managedBy: 'CDK', runtimes: [], memories: [], credentials: [] }) + ); + configIO = new ConfigIO({ baseDir: join(projectRoot, 'agentcore') }); + }); + + afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); + }); + + it('writes the JSON under app// and returns the resulting path', async () => { + const json = JSON.stringify({ + type: 'WEB', + version: 1, + connectionConfiguration: { authType: 'NO_AUTH', seedUrls: ['https://x/'] }, + crawlConfiguration: {}, + }); + const dest = await materializeInlineConnectorConfig({ + kbName: 'mykb', + dataSourceType: 'web-crawler', + jsonContents: json, + configIO, + }); + expect(dest).toBe(join(projectRoot, 'app', 'mykb', 'web-crawler-1.json')); + expect(existsSync(dest)).toBe(true); + // Pretty-printed and round-trips to the original object. + const parsed = JSON.parse(readFileSync(dest, 'utf8')); + expect(parsed.type).toBe('WEB'); + expect(parsed.connectionConfiguration.seedUrls).toEqual(['https://x/']); + // Pretty-print: at least one newline + two-space indent line present. + expect(readFileSync(dest, 'utf8')).toMatch(/\n {2}"type": "WEB"/); + }); + + it('avoids filename collisions by appending an incrementing suffix', async () => { + const json = JSON.stringify({ type: 'WEB' }); + const a = await materializeInlineConnectorConfig({ + kbName: 'kb', + dataSourceType: 'web-crawler', + jsonContents: json, + configIO, + }); + const b = await materializeInlineConnectorConfig({ + kbName: 'kb', + dataSourceType: 'web-crawler', + jsonContents: json, + configIO, + }); + expect(a).toMatch(/web-crawler-1\.json$/); + expect(b).toMatch(/web-crawler-2\.json$/); + expect(existsSync(a)).toBe(true); + expect(existsSync(b)).toBe(true); + }); + + it('rejects malformed JSON before writing anything', async () => { + await expect( + materializeInlineConnectorConfig({ + kbName: 'kb', + dataSourceType: 'web-crawler', + jsonContents: '{ not-json', + configIO, + }) + ).rejects.toThrow(); + expect(existsSync(join(projectRoot, 'app', 'kb'))).toBe(false); + }); +}); diff --git a/src/cli/tui/screens/knowledge-base/groupDataSources.ts b/src/cli/tui/screens/knowledge-base/groupDataSources.ts new file mode 100644 index 000000000..70fa372d5 --- /dev/null +++ b/src/cli/tui/screens/knowledge-base/groupDataSources.ts @@ -0,0 +1,49 @@ +import type { DataSourceTypeFlag } from '../../../operations/knowledge-base/connector-config'; +import type { CapturedDataSource } from './types'; + +/** + * One group passed to a single primitive.add() call: all values share a + * `dataSourceType`. Insertion order across groups follows the order in which + * each type was first seen in the original list (first-seen-type wins). + */ +export interface DataSourceGroup { + dataSourceType: DataSourceTypeFlag; + values: string[]; +} + +/** + * Group captured wizard data sources by `dataSourceType`, preserving: + * - first-seen-type ordering across groups, and + * - original insertion ordering within each group. + * + * Example: + * [ + * { dataSourceType: 's3', value: 's3://a/' }, + * { dataSourceType: 'web-crawler', value: 'app/k/web.json' }, + * { dataSourceType: 's3', value: 's3://b/' }, + * ] + * + * yields: + * [ + * { dataSourceType: 's3', values: ['s3://a/', 's3://b/'] }, + * { dataSourceType: 'web-crawler', values: ['app/k/web.json'] }, + * ] + * + * The Flow dispatches these groups sequentially: the first becomes a + * `primitive.add()` create call; later groups become `appendToExisting` + * appends on the same KB. + */ +export function groupDataSources(dataSources: CapturedDataSource[]): DataSourceGroup[] { + const groups: DataSourceGroup[] = []; + const indexByType = new Map(); + for (const ds of dataSources) { + const idx = indexByType.get(ds.dataSourceType); + if (idx === undefined) { + indexByType.set(ds.dataSourceType, groups.length); + groups.push({ dataSourceType: ds.dataSourceType, values: [ds.value] }); + } else { + groups[idx]!.values.push(ds.value); + } + } + return groups; +} diff --git a/src/cli/tui/screens/knowledge-base/index.ts b/src/cli/tui/screens/knowledge-base/index.ts new file mode 100644 index 000000000..657d74ca1 --- /dev/null +++ b/src/cli/tui/screens/knowledge-base/index.ts @@ -0,0 +1,2 @@ +export { AddKnowledgeBaseFlow } from './AddKnowledgeBaseFlow'; +export type { AddKnowledgeBaseConfig } from './types'; diff --git a/src/cli/tui/screens/knowledge-base/inline-connector-config.ts b/src/cli/tui/screens/knowledge-base/inline-connector-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6bd90c106da50999c3f36b351953939c69d1f6f GIT binary patch literal 3191 zcmb7HO>Y}F5bfE&Vh#Z$CDID%sqiNfCkB)hwqd(X4+XN^T}sP{A{Qjr^2QL*AJJdf zU(z?ER*~&q3glo(x{~AbRO-%L6X+DTD$ zBp;Ly8GY$<3a(=aVJeKWh$SYS$E>1lb7NQz04NU-W}dWzCX}Lu8-}$-ootUla2_qd zqa=>S$4MY(h(T2}l-VMS=hw2BluoQQ5=|!SfmU=rn5-~pv(;L8NNX~8G6rJ{<*Ynf z5^CFq_!Kg5Ec2b?DYGO|@XsprFyBPIff=yk!du;fq|xf0Qh-ER$_D0L*ih3yfBlUR zp6kf>SaII734dTXV8E1ov&+~yY1_Qn)!E_h6#M{ocb$gzaWbLPSi^Km*TNy-#q8$xj>RFMH;n{V zAZ{Z%Z3QGY0-fRLssYt!|WN7x2!J# zain|L0zP8`YFRDhd&uUt1j$Sm{KJTPtwhek4bpTTQvpgjIcZP#h9sp0|e9orEJmwi$phjgHL}P|w{I$Gxq)-WU)QX8%biK#@C~_U01akyx zEhNT##hjL_CUmsj2u`^zfCd{L^)^iQ)F25m!2>H09`k{AQ-0E z_9|Q&Z=6v6>XFR=Q;Ua-ns>t#bxfw!dQ3l$Hh-EN6SBXF`)Lb2ffFozw)=c`Mn9U5^>@HSo&z@+19)<~>ka#x*+Pc%7?lqUM|&69l0PViOS~5j_u`Cc0DuNC8BQXp zDP9z70nd*6=Wu&{jO7*JwP^G`JUO@)`zQ`0KLC3XQIBg;G^vW&(3?|6AMh}iFJ9=_ zrxI^Hy}O>#-b$E4)xyVBEitH%fxWC<2{t|@O$8PW_v~`=Hg;=FS(foGx$VzmvUY#V z{<1;+sjReL4+nEU*h7g21s`D~p1KIPK+8Y|KfmoiFe>{tQrW0xI1qm5q256Uf^huJ z ({ loadDeployedProjectConfig: mockLoadDeployedProjectConfig, })); +// useLogsFlow wraps its load in withCommandRunTelemetry, whose real implementation +// awaits getTelemetryClient()/flush() and never settles in tests — leaving the screen +// stuck in the 'loading' phase. Pass through to the inner fn so the load resolves. +vi.mock('../../../../telemetry/cli-command-run.js', () => ({ + withCommandRunTelemetry: vi.fn((_command: string, _attrs: unknown, fn: (recorder: unknown) => unknown) => + fn({ set: vi.fn(), get: vi.fn(() => ({})) }) + ), +})); + vi.mock('../../../../aws/cloudwatch.js', () => ({ // eslint-disable-next-line require-yield async *streamLogs() { diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx index 8fcd707aa..9800aca6e 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx @@ -1,6 +1,12 @@ import { gatewayTargetPrimitive } from '../../../primitives/registry'; import { ErrorPrompt } from '../../components'; -import { useExistingGateways, useExistingToolNames } from '../../hooks/useCreateMcp'; +import { + useExistingGateways, + useExistingKnowledgeBases, + useExistingRuntimeNames, + useExistingToolNames, + useMcpGatewayNames, +} from '../../hooks/useCreateMcp'; import { AddSuccessScreen } from '../add/AddSuccessScreen'; import { AddIdentityScreen } from '../identity/AddIdentityScreen'; import type { AddIdentityConfig } from '../identity/types'; @@ -12,7 +18,14 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; type FlowState = | { name: 'create-wizard'; resumeConfig?: GatewayTargetWizardState; resumeStep?: AddGatewayTargetStep } | { name: 'creating-credential'; pendingConfig: GatewayTargetWizardState } - | { name: 'create-success'; toolName: string; projectPath: string; loading?: boolean; loadingMessage?: string } + | { + name: 'create-success'; + toolName: string; + projectPath: string; + detail?: string; + loading?: boolean; + loadingMessage?: string; + } | { name: 'error'; message: string }; interface AddGatewayTargetFlowProps { @@ -34,7 +47,10 @@ export function AddGatewayTargetFlow({ onDeploy, }: AddGatewayTargetFlowProps) { const { gateways: existingGateways } = useExistingGateways(); + const { mcpGateways: mcpGatewayNames } = useMcpGatewayNames(); + const { runtimeNames: existingRuntimeNames } = useExistingRuntimeNames(); const { toolNames: existingToolNames } = useExistingToolNames(); + const { knowledgeBases: existingKnowledgeBases } = useExistingKnowledgeBases(); const { credentials } = useExistingCredentials(); const { names: existingIdentityNames } = useExistingIdentityNames(); const { createIdentity } = useCreateIdentity(); @@ -102,6 +118,60 @@ export function AddGatewayTargetFlow({ .catch((err: unknown) => { setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unknown error' }); }); + } else if (config.targetType === 'httpRuntime') { + void gatewayTargetPrimitive + .createHttpRuntimeTarget( + config as { + name: string; + gateway: string; + runtime: string; + endpoint?: string; + outboundAuth?: { type: string; credentialName?: string; scopes?: string[] }; + } + ) + .then((result: { toolName: string }) => { + setFlow({ name: 'create-success', toolName: result.toolName, projectPath: '' }); + }) + .catch((err: unknown) => { + setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unknown error' }); + }); + } else if (config.targetType === 'connector') { + void gatewayTargetPrimitive + .createConnectorGatewayTarget(config) + .then((result: { toolName: string }) => { + // For single-KB Retrieve adds, the primitive also upserts the + // gateway's shared agentic-retrieve target. Surface that to the user. + const detail = + config.connectorId === 'bedrock-knowledge-bases' + ? `Also wired KB '${config.knowledgeBaseId}' into '${config.gateway}-agentic' (bedrock-agentic-retrieve fan-out)` + : undefined; + setFlow({ name: 'create-success', toolName: result.toolName, projectPath: '', detail }); + }) + .catch((err: unknown) => { + setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unknown error' }); + }); + } else if (config.targetType === 'passthrough') { + void gatewayTargetPrimitive + .createPassthroughTarget(config) + .then((result: { toolName: string }) => { + setFlow({ name: 'create-success', toolName: result.toolName, projectPath: '' }); + }) + .catch((err: unknown) => { + setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unknown error' }); + }); + } else if (config.targetType === 'webSearch') { + void gatewayTargetPrimitive + .createWebSearchGatewayTarget(config) + .then((result: { toolName: string }) => { + const detail = + config.excludeDomains && config.excludeDomains.length > 0 + ? `Excluded domains: ${config.excludeDomains.join(', ')}` + : undefined; + setFlow({ name: 'create-success', toolName: result.toolName, projectPath: '', detail }); + }) + .catch((err: unknown) => { + setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unknown error' }); + }); } else { setFlow({ name: 'error', message: `Unsupported target type: ${(config as { targetType: string }).targetType}` }); } @@ -158,9 +228,12 @@ export function AddGatewayTargetFlow({ return ( void; onCreateCredential: (pendingConfig: GatewayTargetWizardState) => void; onExit: () => void; @@ -51,9 +67,12 @@ interface AddGatewayTargetScreenProps { export function AddGatewayTargetScreen({ existingGateways, + mcpGatewayNames = [], + existingRuntimeNames = [], existingToolNames, existingOAuthCredentialNames, existingApiKeyCredentialNames, + existingKnowledgeBases, onComplete, onCreateCredential, onExit, @@ -62,10 +81,14 @@ export function AddGatewayTargetScreen({ }: AddGatewayTargetScreenProps) { const wizard = useAddGatewayTargetWizard(existingGateways, initialConfig, initialStep); + // Load endpoints for the selected runtime (used by runtime-endpoint step) + const { endpoints: runtimeEndpoints, loaded: runtimeEndpointsLoaded } = useRuntimeEndpoints(wizard.config.runtime); + // Tracks which credential type sub-step is active within either auth step. // null = showing the auth type picker; 'OAUTH'/'API_KEY' = showing credential list. const [pendingCredType, setPendingCredType] = useState<'OAUTH' | 'API_KEY' | null>(null); const [filterPath, setFilterPathLocal] = useState(null); + const [customSigningService, setCustomSigningService] = useState(false); // ── Step flags ── const isGatewayStep = wizard.step === 'gateway'; @@ -79,17 +102,67 @@ export function AddGatewayTargetScreen({ const isSchemaSourceStep = wizard.step === 'schema-source'; const isLambdaArnStep = wizard.step === 'lambda-arn'; const isToolSchemaStep = wizard.step === 'tool-schema'; + const isRuntimeStep = wizard.step === 'runtime'; + const isRuntimeEndpointStep = wizard.step === 'runtime-endpoint'; + const isKbSelectStep = wizard.step === 'kb-select'; + const isKbIdStep = wizard.step === 'kb-id'; + const isPassthroughEndpointStep = wizard.step === 'passthrough-endpoint'; + const isPassthroughProtocolStep = wizard.step === 'passthrough-protocol'; + const isPassthroughStickinessStep = wizard.step === 'passthrough-stickiness'; + const isExcludeDomainsStep = wizard.step === 'exclude-domains'; const isConfirmStep = wizard.step === 'confirm'; const isAuthStep = isOutboundAuthStep || isApiGatewayAuthStep; const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0; + // Auto-select the gateway for webSearch targets when there's exactly one option. + useEffect(() => { + if ( + isGatewayStep && + wizard.config.targetType === 'webSearch' && + existingGateways.length === 1 && + !wizard.config.gateway + ) { + wizard.setGateway(existingGateways[0]!); + } + }, [isGatewayStep, wizard.config.targetType, existingGateways, wizard.config.gateway, wizard.setGateway]); + // ── Selectable item lists ── + const isNonMcpTarget = wizard.config.targetType === 'httpRuntime' || wizard.config.targetType === 'passthrough'; + const isHttpRuntimeTarget = isNonMcpTarget; + const runtimeItems: SelectableItem[] = useMemo( + () => existingRuntimeNames.map(r => ({ id: r, title: r })), + [existingRuntimeNames] + ); + const runtimeEndpointItems: SelectableItem[] = useMemo( + () => [ + { id: 'DEFAULT', title: 'DEFAULT (latest version)' }, + ...runtimeEndpoints.map(ep => ({ id: ep.name, title: `${ep.name} (v${ep.version})` })), + ], + [runtimeEndpoints] + ); const gatewayItems: SelectableItem[] = useMemo( - () => existingGateways.map(g => ({ id: g, title: g })), - [existingGateways] + () => + existingGateways.map(g => { + const isMcpOnly = isHttpRuntimeTarget && mcpGatewayNames.includes(g); + return { + id: g, + title: isMcpOnly ? `${g} (MCP — not compatible with HTTP Runtime)` : g, + disabled: isMcpOnly, + }; + }), + [existingGateways, mcpGatewayNames, isHttpRuntimeTarget] ); const targetTypeItems: SelectableItem[] = useMemo( - () => TARGET_TYPE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), + () => + TARGET_TYPE_OPTIONS.map(o => { + const gated = (o.id === 'passthrough' || o.id === 'webSearch') && !isGatedFeaturesEnabled(); + return { + id: o.id, + title: o.title, + description: gated ? 'Coming soon' : o.description, + disabled: gated, + }; + }), [] ); const outboundAuthItems: SelectableItem[] = useMemo( @@ -101,6 +174,10 @@ export function AddGatewayTargetScreen({ })), [wizard.config.targetType] ); + const passthroughProtocolItems: SelectableItem[] = useMemo( + () => PASSTHROUGH_PROTOCOL_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), + [] + ); const apiGatewayAuthItems: SelectableItem[] = useMemo( () => API_GATEWAY_AUTH_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), [] @@ -113,6 +190,21 @@ export function AddGatewayTargetScreen({ () => buildCredentialItems(existingApiKeyCredentialNames, 'API key credential'), [existingApiKeyCredentialNames] ); + const knowledgeBaseItems: SelectableItem[] = useMemo( + () => [ + ...existingKnowledgeBases.map(name => ({ + id: name, + title: name, + description: 'Project Knowledge Base (resolved at synth)', + })), + { + id: ENTER_KB_ID_MANUALLY, + title: 'Enter an existing KB ID manually...', + description: 'Provide a 10-character Bedrock Knowledge Base ID', + }, + ], + [existingKnowledgeBases] + ); // ── Auth completion callbacks ── // Shared handler that routes to the correct wizard setter based on the active step. @@ -143,9 +235,13 @@ export function AddGatewayTargetScreen({ // ── Navigation hooks ── const targetTypeNav = useListNavigation({ items: targetTypeItems, - onSelect: item => wizard.setTargetType(item.id as GatewayTargetType), + onSelect: item => { + if ((item as SelectableItem & { disabled?: boolean }).disabled) return; + wizard.setTargetType(item.id as GatewayTargetType); + }, onExit: () => wizard.goBack(), isActive: isTargetTypeStep, + isDisabled: item => item.disabled === true, }); const gatewayNav = useListNavigation({ @@ -155,13 +251,60 @@ export function AddGatewayTargetScreen({ isActive: isGatewayStep && !noGatewaysAvailable, }); - // Outbound auth type selection (for mcpServer, openApiSchema) + const runtimeNav = useListNavigation({ + items: runtimeItems, + onSelect: item => wizard.setRuntime(item.id), + onExit: () => wizard.goBack(), + isActive: isRuntimeStep && runtimeItems.length > 0, + }); + + // Auto-skip runtime-endpoint step when the runtime has no endpoints defined. + // Must wait until endpoints have been loaded to avoid a race condition where the + // step is skipped before the async fetch completes. + const hasRuntimeEndpoints = runtimeEndpoints.length > 0; + React.useEffect(() => { + if (isRuntimeEndpointStep && runtimeEndpointsLoaded && !hasRuntimeEndpoints) { + // No endpoints defined — skip to gateway step (select DEFAULT implicitly) + wizard.setRuntimeEndpoint(undefined); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRuntimeEndpointStep, runtimeEndpointsLoaded, hasRuntimeEndpoints]); + + const runtimeEndpointNav = useListNavigation({ + items: runtimeEndpointItems, + onSelect: item => { + wizard.setRuntimeEndpoint(item.id === 'DEFAULT' ? undefined : item.id); + }, + onExit: () => wizard.goBack(), + isActive: isRuntimeEndpointStep && hasRuntimeEndpoints, + }); + + // Knowledge Base selection (connector branch). Selecting a project KB stores + // its name on the wizard (resolved at synth from application.knowledgeBases). + // Selecting "Enter an existing KB ID manually..." advances to the kb-id + // text-input sub-step for an external KB. + const knowledgeBaseNav = useListNavigation({ + items: knowledgeBaseItems, + onSelect: item => { + if (item.id === ENTER_KB_ID_MANUALLY) { + wizard.beginManualKbId(); + } else { + wizard.setKnowledgeBaseId(item.id); + } + }, + onExit: () => wizard.goBack(), + isActive: isKbSelectStep, + }); + + // Outbound auth type selection (for mcpServer, openApiSchema, passthrough) const outboundAuthNav = useListNavigation({ items: outboundAuthItems, onSelect: item => { - const authType = item.id as 'OAUTH' | 'API_KEY' | 'NONE'; + const authType = item.id as 'OAUTH' | 'API_KEY' | 'NONE' | 'GATEWAY_IAM_ROLE' | 'JWT_PASSTHROUGH'; if (authType === 'NONE') { completeAuth({ type: 'NONE' }); + } else if (authType === 'GATEWAY_IAM_ROLE' || authType === 'JWT_PASSTHROUGH') { + wizard.setOutboundAuth({ type: authType }); } else { selectAuthType(authType); } @@ -215,6 +358,33 @@ export function AddGatewayTargetScreen({ isActive: isAuthStep && pendingCredType === 'API_KEY', }); + // Passthrough protocol selection + const passthroughProtocolNav = useListNavigation({ + items: passthroughProtocolItems, + onSelect: item => wizard.setPassthroughProtocol(item.id as PassthroughProtocolType), + onExit: () => wizard.goBack(), + isActive: isPassthroughProtocolStep, + }); + + // Signing service selection for passthrough GATEWAY_IAM_ROLE + const signingServiceNav = useListNavigation({ + items: [ + { id: 'execute-api', title: 'execute-api' }, + { id: 'lambda', title: 'lambda' }, + { id: 'bedrock-agentcore', title: 'bedrock-agentcore' }, + { id: 'custom', title: 'Custom...' }, + ], + onSelect: item => { + if (item.id === 'custom') { + setCustomSigningService(true); + } else { + wizard.setSigningService(item.id); + } + }, + onExit: () => wizard.goBack(), + isActive: wizard.step === 'signing-service' && !customSigningService, + }); + // Confirm step useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], @@ -246,6 +416,44 @@ export function AddGatewayTargetScreen({ lambdaArn: c.lambdaArn!, toolSchemaFile: c.toolSchemaFile!, }); + } else if (c.targetType === 'httpRuntime') { + onComplete({ + targetType: 'httpRuntime', + name: c.name, + gateway: c.gateway!, + runtime: c.runtime!, + endpoint: c.endpoint, + outboundAuth: c.outboundAuth, + }); + } else if (c.targetType === 'webSearch') { + onComplete({ + targetType: 'webSearch', + name: c.name, + gateway: c.gateway!, + ...(c.excludeDomains && c.excludeDomains.length > 0 ? { excludeDomains: c.excludeDomains } : {}), + }); + } else if (c.targetType === 'connector') { + // KB connector path. `bedrock-agentic-retrieve` is gateway-managed by + // the Add Knowledge Base flow, so the TUI only emits + // `bedrock-knowledge-bases` here. + onComplete({ + targetType: 'connector', + connectorId: 'bedrock-knowledge-bases', + name: c.name, + gateway: c.gateway!, + knowledgeBaseId: c.knowledgeBaseId!, + }); + } else if (c.targetType === 'passthrough') { + onComplete({ + targetType: 'passthrough', + name: c.name, + gateway: c.gateway!, + passthroughEndpoint: c.passthroughEndpoint!, + protocolType: c.passthroughProtocol ?? 'CUSTOM', + stickinessIdentifier: c.stickinessIdentifier, + stickinessTimeout: c.stickinessTimeout, + outboundAuth: c.outboundAuth, + } as PassthroughTargetConfig); } else { onComplete({ targetType: 'mcpServer', @@ -254,7 +462,7 @@ export function AddGatewayTargetScreen({ endpoint: c.endpoint!, gateway: c.gateway!, toolDefinition: c.toolDefinition!, - outboundAuth: c.outboundAuth, + outboundAuth: c.outboundAuth as McpServerTargetConfig['outboundAuth'], }); } }, @@ -266,6 +474,7 @@ export function AddGatewayTargetScreen({ }); // ── Render ── + const isSigningRegionStep = wizard.step === 'signing-region'; const helpText = isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL : isTextStep || @@ -274,7 +483,13 @@ export function AddGatewayTargetScreen({ isToolFiltersStep || isSchemaSourceStep || isLambdaArnStep || - isToolSchemaStep + isToolSchemaStep || + isRuntimeStep || + isKbIdStep || + isPassthroughEndpointStep || + isPassthroughStickinessStep || + isSigningRegionStep || + isExcludeDomainsStep ? HELP_TEXT.TEXT_INPUT : HELP_TEXT.NAVIGATE_SELECT; @@ -480,6 +695,146 @@ export function AddGatewayTargetScreen({ /> )} + {isRuntimeStep && runtimeItems.length > 0 && ( + + )} + + {isRuntimeStep && runtimeItems.length === 0 && ( + wizard.setRuntime(value)} + onCancel={() => wizard.goBack()} + customValidation={(value: string) => { + if (!value.trim()) return 'Runtime is required'; + return true; + }} + /> + )} + + {isKbSelectStep && ( + + )} + + {isKbIdStep && ( + wizard.goBack()} + customValidation={(value: string) => { + if (!value.trim()) return 'KB ID is required'; + if (!REAL_KB_ID_PATTERN.test(value)) { + return 'Must be a 10-character uppercase alphanumeric Bedrock KB ID (e.g. ABCDE12345)'; + } + return true; + }} + /> + )} + + {isRuntimeEndpointStep && !runtimeEndpointsLoaded && Loading runtime endpoints...} + + {isRuntimeEndpointStep && runtimeEndpointsLoaded && hasRuntimeEndpoints && ( + + )} + + {isPassthroughEndpointStep && ( + wizard.goBack()} + customValidation={(value: string) => { + if (!value.startsWith('https://')) return 'Must start with https://'; + if (!/^https:\/\/[a-zA-Z0-9\-.]+(:[0-9]{1,5})?(\/.*)?$/.test(value)) return 'Must be a valid HTTPS URL'; + return true; + }} + /> + )} + + {isPassthroughProtocolStep && ( + + )} + + {isPassthroughStickinessStep && ( + { + if (!value.trim()) { + wizard.setStickinessConfig(undefined, undefined); + } else { + // For simplicity in TUI, use default timeout (skip timeout input) + wizard.setStickinessConfig(value.trim(), undefined); + } + }} + onCancel={() => wizard.goBack()} + /> + )} + + {wizard.step === 'signing-service' && ( + + )} + + {wizard.step === 'signing-region' && ( + { + wizard.setSigningRegion(value.trim() || undefined); + }} + onCancel={() => wizard.goBack()} + /> + )} + + {isExcludeDomainsStep && ( + { + const domains = value + .split(',') + .map(d => d.trim()) + .filter(d => d.length > 0); + wizard.setExcludeDomains(domains.length > 0 ? domains : undefined); + }} + onCancel={() => wizard.goBack()} + /> + )} + {isConfirmStep && ( { expect(config.targetType).toBe('lambdaFunctionArn'); }); + it('narrows to BedrockKnowledgeBasesConnectorTargetConfig when targetType is connector', () => { + const config: AddGatewayTargetConfig = { + targetType: 'connector', + connectorId: 'bedrock-knowledge-bases', + name: 'kb-target', + gateway: 'my-gateway', + knowledgeBaseId: 'my-project-kb', + }; + + if (config.targetType === 'connector') { + // TypeScript narrows on the connectorId discriminator inside the union. + if (config.connectorId === 'bedrock-knowledge-bases') { + expect(config.knowledgeBaseId).toBe('my-project-kb'); + expect(config.gateway).toBe('my-gateway'); + } + } + }); + + it('BedrockKnowledgeBasesConnectorTargetConfig accepts a literal 10-char external KB ID', () => { + const config: BedrockKnowledgeBasesConnectorTargetConfig = { + targetType: 'connector', + connectorId: 'bedrock-knowledge-bases', + name: 'kb-target', + gateway: 'gw', + knowledgeBaseId: 'ABCDE12345', + }; + expect(config.connectorId).toBe('bedrock-knowledge-bases'); + expect(config.knowledgeBaseId).toBe('ABCDE12345'); + }); + it('three-way dispatch handles all target types', () => { const configs: AddGatewayTargetConfig[] = [ { diff --git a/src/cli/tui/screens/mcp/__tests__/types.test.ts b/src/cli/tui/screens/mcp/__tests__/types.test.ts index cac8e71f6..a3306544b 100644 --- a/src/cli/tui/screens/mcp/__tests__/types.test.ts +++ b/src/cli/tui/screens/mcp/__tests__/types.test.ts @@ -1,4 +1,4 @@ -import { AUTHORIZER_TYPE_OPTIONS, SKIP_FOR_NOW, TARGET_TYPE_OPTIONS } from '../types.js'; +import { AUTHORIZER_TYPE_OPTIONS, ENTER_KB_ID_MANUALLY, SKIP_FOR_NOW, TARGET_TYPE_OPTIONS } from '../types.js'; import { describe, expect, it } from 'vitest'; describe('MCP types constants', () => { @@ -14,4 +14,16 @@ describe('MCP types constants', () => { const mcpServer = TARGET_TYPE_OPTIONS.find((opt: { id: string }) => opt.id === 'mcpServer'); expect(mcpServer).toBeDefined(); }); + + it('TARGET_TYPE_OPTIONS exposes a connector (Knowledge Base) entry', () => { + const connector = TARGET_TYPE_OPTIONS.find((opt: { id: string }) => opt.id === 'connector'); + expect(connector).toBeDefined(); + expect(connector?.title).toBe('Knowledge Base'); + }); + + it('ENTER_KB_ID_MANUALLY is a stable sentinel id', () => { + // Sentinel for the "Enter an existing KB ID manually..." picker entry — + // the screen branches on this exact id when the user picks the manual path. + expect(ENTER_KB_ID_MANUALLY).toBe('__enter_kb_id__'); + }); }); diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 59be5abe7..482b47ff7 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -6,6 +6,7 @@ import type { GatewayPolicyEngineConfiguration, GatewayTargetType, NodeRuntime, + PassthroughProtocolType, SchemaSource, ToolDefinition, } from '../../../../schema'; @@ -27,6 +28,8 @@ export type AddGatewayStep = export interface AddGatewayConfig { name: string; description: string; + /** Protocol type for the gateway. Omit for MCP (default). */ + protocolType?: 'MCP' | 'None'; /** Authorization type for the gateway */ authorizerType: GatewayAuthorizerType; /** JWT authorizer configuration (when authorizerType is 'CUSTOM_JWT') */ @@ -94,6 +97,16 @@ export type AddGatewayTargetStep = | 'schema-source' | 'lambda-arn' | 'tool-schema' + | 'runtime' + | 'runtime-endpoint' + | 'kb-select' + | 'kb-id' + | 'passthrough-endpoint' + | 'passthrough-protocol' + | 'passthrough-stickiness' + | 'signing-service' + | 'signing-region' + | 'exclude-domains' | 'confirm'; export type TargetLanguage = 'Python' | 'TypeScript' | 'Other'; @@ -113,9 +126,11 @@ export interface GatewayTargetWizardState { host?: ComputeHost; toolDefinition?: ToolDefinition; outboundAuth?: { - type: 'OAUTH' | 'API_KEY' | 'NONE'; + type: 'OAUTH' | 'API_KEY' | 'NONE' | 'GATEWAY_IAM_ROLE' | 'JWT_PASSTHROUGH'; credentialName?: string; scopes?: string[]; + service?: string; + region?: string; }; restApiId?: string; stage?: string; @@ -124,6 +139,30 @@ export interface GatewayTargetWizardState { schemaSource?: SchemaSource; lambdaArn?: string; toolSchemaFile?: string; + /** Runtime name reference for httpRuntime targets */ + runtime?: string; + /** Knowledge Base reference for connector targets — either a project KB name or a literal 10-char KB ID. */ + knowledgeBaseId?: string; + /** + * Connector identifier when targetType is 'connector'. Only + * `bedrock-knowledge-bases` is exposed in the TUI; `bedrock-agentic-retrieve` + * is gateway-managed by the Add Knowledge Base flow. + */ + connectorId?: 'bedrock-knowledge-bases' | 'bedrock-agentic-retrieve'; + /** Passthrough endpoint URL for passthrough targets */ + passthroughEndpoint?: string; + /** Passthrough protocol type for passthrough targets */ + passthroughProtocol?: PassthroughProtocolType; + /** Stickiness routing identifier for passthrough targets */ + stickinessIdentifier?: string; + /** Stickiness timeout in seconds for passthrough targets */ + stickinessTimeout?: number; + /** SigV4 signing service for passthrough GATEWAY_IAM_ROLE auth */ + signingService?: string; + /** SigV4 signing region for passthrough GATEWAY_IAM_ROLE auth */ + signingRegion?: string; + /** Optional list of domains to exclude (webSearch target type only). */ + excludeDomains?: string[]; } // ───────────────────────────────────────────────────────────────────────────── @@ -178,11 +217,75 @@ export interface LambdaFunctionArnTargetConfig { toolSchemaFile: string; } +export interface HttpRuntimeTargetConfig { + targetType: 'httpRuntime'; + name: string; + gateway: string; + runtime: string; + endpoint?: string; + outboundAuth?: { type: string; credentialName?: string; scopes?: string[] }; +} + +interface ConnectorTargetConfigBase { + targetType: 'connector'; + name: string; + gateway: string; + description?: string; +} + +export interface BedrockKnowledgeBasesConnectorTargetConfig extends ConnectorTargetConfigBase { + connectorId: 'bedrock-knowledge-bases'; + /** + * Either a project KB name (a knowledgeBases[] entry, resolved at synth + * via application.knowledgeBases) or a literal 10-char external KB ID. + */ + knowledgeBaseId: string; +} + +export interface BedrockAgenticRetrieveConnectorTargetConfig extends ConnectorTargetConfigBase { + connectorId: 'bedrock-agentic-retrieve'; + /** Fan-out: project KB names and/or literal 10-char external KB IDs. */ + knowledgeBaseIds: string[]; +} + +export type ConnectorTargetConfig = + | BedrockKnowledgeBasesConnectorTargetConfig + | BedrockAgenticRetrieveConnectorTargetConfig; + +export interface PassthroughTargetConfig { + targetType: 'passthrough'; + name: string; + gateway: string; + passthroughEndpoint: string; + protocolType?: PassthroughProtocolType; + stickinessIdentifier?: string; + stickinessTimeout?: number; + outboundAuth?: { + type: 'OAUTH' | 'API_KEY' | 'NONE' | 'GATEWAY_IAM_ROLE' | 'JWT_PASSTHROUGH'; + credentialName?: string; + scopes?: string[]; + service?: string; + region?: string; + }; +} + +export interface WebSearchTargetConfig { + targetType: 'webSearch'; + name: string; + gateway: string; + /** Optional list of domains to exclude from web search results. */ + excludeDomains?: string[]; +} + export type AddGatewayTargetConfig = | McpServerTargetConfig | ApiGatewayTargetConfig | SchemaBasedTargetConfig - | LambdaFunctionArnTargetConfig; + | LambdaFunctionArnTargetConfig + | HttpRuntimeTargetConfig + | ConnectorTargetConfig + | PassthroughTargetConfig + | WebSearchTargetConfig; export const MCP_TOOL_STEP_LABELS: Record = { name: 'Name', @@ -199,6 +302,16 @@ export const MCP_TOOL_STEP_LABELS: Record = { 'schema-source': 'Schema Source', 'lambda-arn': 'Lambda ARN', 'tool-schema': 'Tool Schema File', + runtime: 'Runtime', + 'runtime-endpoint': 'Endpoint', + 'kb-select': 'Knowledge Base', + 'kb-id': 'KB ID', + 'passthrough-endpoint': 'Endpoint', + 'passthrough-protocol': 'Protocol', + 'passthrough-stickiness': 'Stickiness', + 'signing-service': 'Signing Service', + 'signing-region': 'Signing Region', + 'exclude-domains': 'Exclude Domains', confirm: 'Confirm', }; @@ -218,6 +331,7 @@ export const SKIP_FOR_NOW = 'skip-for-now' as const; export const NONE_SELECTION = '__none__' as const; export const TARGET_TYPE_OPTIONS = [ + // MCP targets { id: 'mcpServer', title: 'MCP Server endpoint', description: 'Connect to an existing MCP-compatible server' }, { id: 'apiGateway', @@ -231,6 +345,38 @@ export const TARGET_TYPE_OPTIONS = [ title: 'Lambda function', description: 'Connect to an existing AWS Lambda function', }, + // HTTP targets + { + id: 'httpRuntime', + title: 'HTTP Runtime', + description: 'Route HTTP traffic to an AgentCore runtime', + }, + { + id: 'connector', + title: 'Knowledge Base', + description: 'Wire an existing Knowledge Base to this gateway as a connector target', + }, + { + id: 'passthrough', + title: 'Passthrough', + description: 'Route to external HTTPS endpoint', + }, + { + id: 'webSearch', + title: 'Amazon Web Search', + description: 'Wire the Amazon Web Search managed connector to this gateway', + }, +] as const; + +/** Sentinel ID for the "Enter an existing KB ID manually..." option in the KB-select step. */ +export const ENTER_KB_ID_MANUALLY = '__enter_kb_id__' as const; + +/** Passthrough protocol options. CUSTOM is the default (first). */ +export const PASSTHROUGH_PROTOCOL_OPTIONS = [ + { id: 'CUSTOM', title: 'CUSTOM', description: 'Generic HTTP/REST endpoint (default)' }, + { id: 'MCP', title: 'MCP', description: 'Model Context Protocol server' }, + { id: 'A2A', title: 'A2A', description: 'Agent-to-Agent protocol' }, + { id: 'INFERENCE', title: 'INFERENCE', description: 'Model inference endpoint' }, ] as const; export const TARGET_LANGUAGE_OPTIONS = [ @@ -249,6 +395,8 @@ const AUTH_OPTION_LABELS = { NONE: { title: 'No authorization', description: 'No outbound authentication' }, OAUTH: { title: 'OAuth 2LO', description: 'OAuth 2.0 client credentials' }, API_KEY: { title: 'API Key', description: 'API key credential' }, + GATEWAY_IAM_ROLE: { title: 'Gateway IAM Role', description: 'Gateway signs with SigV4' }, + JWT_PASSTHROUGH: { title: 'JWT Passthrough', description: 'Forward caller JWT token' }, } as const; /** Derive the outbound auth UI options for a given target type from the centralized config. */ diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index bfef7d4ac..525ffe456 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -1,5 +1,11 @@ import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib'; -import type { ApiGatewayHttpMethod, GatewayTargetType, SchemaSource, ToolDefinition } from '../../../../schema'; +import type { + ApiGatewayHttpMethod, + GatewayTargetType, + PassthroughProtocolType, + SchemaSource, + ToolDefinition, +} from '../../../../schema'; import type { AddGatewayTargetStep, GatewayTargetWizardState } from './types'; import { useCallback, useMemo, useState } from 'react'; @@ -47,6 +53,31 @@ export function useAddGatewayTargetWizard( case 'lambdaFunctionArn': baseSteps.push('lambda-arn', 'tool-schema', 'gateway'); break; + case 'httpRuntime': + baseSteps.push('runtime', 'runtime-endpoint', 'gateway', 'outbound-auth'); + break; + case 'connector': + // Connector (Knowledge Base) flow: select a KB (project name or + // literal 10-char ID), then attach to a gateway. No outbound auth — + // connector targets are managed by the gateway IAM role. + baseSteps.push('kb-select', 'gateway'); + break; + case 'passthrough': + baseSteps.push( + 'passthrough-endpoint', + 'passthrough-protocol', + 'passthrough-stickiness', + 'gateway', + 'outbound-auth', + 'signing-service', + 'signing-region' + ); + break; + case 'webSearch': + // Amazon Web Search flow: pick a gateway, optionally specify domains + // to exclude. No outbound auth — managed by the gateway IAM role. + baseSteps.push('gateway', 'exclude-domains'); + break; case 'mcpServer': default: baseSteps.push('endpoint', 'gateway', 'outbound-auth'); @@ -57,10 +88,15 @@ export function useAddGatewayTargetWizard( return baseSteps; }, [config.targetType]); - const currentIndex = steps.indexOf(step); + // The 'kb-id' step is a sub-step of 'kb-select' for manual literal-KB-ID entry. + // It is not part of the canonical step list, so map it onto kb-select for + // navigation/index purposes. + const stepForIndex: AddGatewayTargetStep = step === 'kb-id' ? 'kb-select' : step; + const currentIndex = steps.indexOf(stepForIndex); const goToNextStep = useCallback(() => { - const idx = steps.indexOf(step); + const lookup = step === 'kb-id' ? 'kb-select' : step; + const idx = steps.indexOf(lookup); const next = steps[idx + 1]; if (idx >= 0 && next) { setStep(next); @@ -68,9 +104,14 @@ export function useAddGatewayTargetWizard( }, [steps, step]); const goBack = useCallback(() => { + // From the manual KB-ID entry, fall back to the KB selection picker. + if (step === 'kb-id') { + setStep('kb-select'); + return; + } const prevStep = steps[currentIndex - 1]; if (prevStep) setStep(prevStep); - }, [currentIndex, steps]); + }, [currentIndex, steps, step]); const setName = useCallback( (name: string) => { @@ -87,7 +128,16 @@ export function useAddGatewayTargetWizard( ); const setTargetType = useCallback((targetType: GatewayTargetType) => { - setConfig(c => ({ ...c, targetType })); + // KB connector targets default to 'bedrock-knowledge-bases' for the TUI; + // 'bedrock-agentic-retrieve' is gateway-managed by the Add Knowledge Base + // flow and not directly exposed here. webSearch targets carry no connectorId. + const connectorIdDefault = targetType === 'connector' ? ('bedrock-knowledge-bases' as const) : undefined; + setConfig(c => ({ + ...c, + targetType, + ...(connectorIdDefault ? { connectorId: connectorIdDefault } : { connectorId: undefined }), + ...(targetType !== 'webSearch' ? { excludeDomains: undefined } : {}), + })); // Cannot use goToNextStep() here — config.targetType is changing, which triggers // useMemo to recompute steps, but goToNextStep captures the OLD steps via closure. // Must explicitly set the first type-specific step. @@ -102,6 +152,18 @@ export function useAddGatewayTargetWizard( case 'lambdaFunctionArn': setStep('lambda-arn'); break; + case 'httpRuntime': + setStep('runtime'); + break; + case 'connector': + setStep('kb-select'); + break; + case 'passthrough': + setStep('passthrough-endpoint'); + break; + case 'webSearch': + setStep('gateway'); + break; case 'mcpServer': default: setStep('endpoint'); @@ -137,14 +199,24 @@ export function useAddGatewayTargetWizard( ); const setOutboundAuth = useCallback( - (outboundAuth: { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string }) => { + (outboundAuth: { + type: 'OAUTH' | 'API_KEY' | 'NONE' | 'GATEWAY_IAM_ROLE' | 'JWT_PASSTHROUGH'; + credentialName?: string; + }) => { setConfig(c => ({ ...c, outboundAuth, })); - goToNextStep(); + // For GATEWAY_IAM_ROLE, next step is signing-service (handled via steps array) + // For JWT_PASSTHROUGH and others, skip signing steps (go to confirm) + if (outboundAuth.type === 'GATEWAY_IAM_ROLE') { + setStep('signing-service'); + } else { + // Skip signing-service and signing-region, go to confirm + setStep('confirm'); + } }, - [goToNextStep] + [] ); const reset = useCallback(() => { @@ -200,6 +272,111 @@ export function useAddGatewayTargetWizard( [goToNextStep] ); + const setRuntime = useCallback( + (runtime: string) => { + setConfig(c => ({ ...c, runtime })); + goToNextStep(); + }, + [goToNextStep] + ); + + const setRuntimeEndpoint = useCallback( + (endpoint: string | undefined) => { + setConfig(c => ({ ...c, endpoint })); + goToNextStep(); + }, + [goToNextStep] + ); + + /** + * Set the Knowledge Base reference (a project KB name or a literal 10-char + * external KB ID) and advance to the gateway step. The wizard's `name` + * field defaults to the KB reference if the user hasn't typed one yet. + */ + const setKnowledgeBaseId = useCallback( + (knowledgeBaseId: string) => { + setConfig(c => ({ + ...c, + knowledgeBaseId, + name: c.name || knowledgeBaseId, + })); + goToNextStep(); + }, + [goToNextStep] + ); + + const setPassthroughEndpoint = useCallback( + (passthroughEndpoint: string) => { + setConfig(c => ({ ...c, passthroughEndpoint })); + goToNextStep(); + }, + [goToNextStep] + ); + + const setPassthroughProtocol = useCallback( + (passthroughProtocol: PassthroughProtocolType) => { + setConfig(c => ({ ...c, passthroughProtocol })); + goToNextStep(); + }, + [goToNextStep] + ); + + const setStickinessConfig = useCallback( + (identifier?: string, timeout?: number) => { + setConfig(c => ({ + ...c, + stickinessIdentifier: identifier, + stickinessTimeout: timeout, + })); + goToNextStep(); + }, + [goToNextStep] + ); + + /** Switch from the kb-select picker to the manual literal-ID entry step. */ + const beginManualKbId = useCallback(() => { + setStep('kb-id'); + }, []); + + const setSigningService = useCallback( + (signingService: string) => { + setConfig(c => ({ + ...c, + signingService, + outboundAuth: { ...c.outboundAuth!, service: signingService }, + })); + goToNextStep(); + }, + [goToNextStep] + ); + + const setSigningRegion = useCallback( + (signingRegion?: string) => { + setConfig(c => ({ + ...c, + signingRegion, + outboundAuth: { ...c.outboundAuth!, region: signingRegion }, + })); + goToNextStep(); + }, + [goToNextStep] + ); + + /** + * Set the optional list of domains to exclude (web-search connector only) + * and advance to confirm. An empty submission clears the field. + */ + const setExcludeDomains = useCallback( + (excludeDomains: string[] | undefined) => { + setConfig(c => ({ + ...c, + excludeDomains: excludeDomains && excludeDomains.length > 0 ? excludeDomains : undefined, + })); + goToNextStep(); + }, + [goToNextStep] + ); + return { config, step, @@ -219,6 +396,16 @@ export function useAddGatewayTargetWizard( setApiGatewayAuth, setLambdaArn, setToolSchemaFile, + setRuntime, + setRuntimeEndpoint, + setKnowledgeBaseId, + beginManualKbId, + setPassthroughEndpoint, + setPassthroughProtocol, + setStickinessConfig, + setSigningService, + setSigningRegion, + setExcludeDomains, reset, }; } diff --git a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx index 243eba4e7..309c2c71e 100644 --- a/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx +++ b/src/cli/tui/screens/online-eval/AddOnlineEvalFlow.tsx @@ -59,14 +59,6 @@ export function AddOnlineEvalFlow({ isInteractive = true, onExit, onBack, onDev, const runtimesList = projectSpec.runtimes ?? []; const agentNames = runtimesList.map(a => a.name); - if (agentNames.length === 0) { - setFlow({ - name: 'error', - message: 'No agents found in project. Add an agent first with `agentcore add agent`.', - }); - return; - } - // Build runtime info with endpoints for the endpoint picker const runtimesInfo: RuntimeInfoForEval[] = runtimesList.map(r => ({ name: r.name, diff --git a/src/cli/tui/screens/online-eval/AddOnlineEvalScreen.tsx b/src/cli/tui/screens/online-eval/AddOnlineEvalScreen.tsx index fd5fafcf6..3846711cf 100644 --- a/src/cli/tui/screens/online-eval/AddOnlineEvalScreen.tsx +++ b/src/cli/tui/screens/online-eval/AddOnlineEvalScreen.tsx @@ -12,11 +12,11 @@ import { import { HELP_TEXT } from '../../constants'; import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; -import type { AddOnlineEvalConfig, EvaluatorItem, RuntimeEndpointEntry } from './types'; +import type { AddOnlineEvalConfig, EvaluatorItem, OnlineEvalSource, RuntimeEndpointEntry } from './types'; import { DEFAULT_SAMPLING_RATE, ONLINE_EVAL_STEP_LABELS } from './types'; import { useAddOnlineEvalWizard } from './useAddOnlineEvalWizard'; import { Box, Text } from 'ink'; -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; /** Runtime info with endpoints, passed from the parent flow. */ export interface RuntimeInfoForEval { @@ -44,13 +44,16 @@ export function AddOnlineEvalScreen({ }: AddOnlineEvalScreenProps) { const wizard = useAddOnlineEvalWizard(agentNames.length); - // Auto-set agent when there's only one + // State for the repeating log group input + const [logGroupEntries, setLogGroupEntries] = useState([]); + + // Auto-set agent when there's only one and source is agentcore-runtime const effectiveConfig = useMemo(() => { - if (agentNames.length === 1 && !wizard.config.agent) { + if (wizard.source === 'agentcore-runtime' && agentNames.length === 1 && !wizard.config.agent) { return { ...wizard.config, agent: agentNames[0]! }; } return wizard.config; - }, [wizard.config, agentNames]); + }, [wizard.config, wizard.source, agentNames]); // Determine endpoints for the currently selected agent const agentEndpoints = useMemo(() => { @@ -60,18 +63,34 @@ export function AddOnlineEvalScreen({ return rt?.endpoints ?? []; }, [effectiveConfig.agent, runtimes]); - // Skip endpoint step when the selected agent has no endpoints + // Skip steps based on source selection const shouldSkipStep = useCallback( (s: string) => { - if (s === 'endpoint' && agentEndpoints.length === 0) return true; + if (s === 'endpoint' && (wizard.source === 'cloudwatch-logs' || agentEndpoints.length === 0)) return true; + if (s === 'agent' && wizard.source === 'cloudwatch-logs') return true; + if (s === 'logGroupNames' && wizard.source === 'agentcore-runtime') return true; + if (s === 'serviceName' && wizard.source === 'agentcore-runtime') return true; return false; }, - [agentEndpoints.length] + [wizard.source, agentEndpoints.length] ); useEffect(() => { wizard.setSkipCheck(shouldSkipStep); - }, [shouldSkipStep]); // wizard.setSkipCheck is stable (useCallback with no deps) + }, [shouldSkipStep, wizard]); // wizard.setSkipCheck is stable (useCallback with no deps) + + // Source selection items + const sourceItems: SelectableItem[] = useMemo( + () => [ + { id: 'agentcore-runtime', title: 'AgentCore Runtime', description: 'Monitor a managed AgentCore agent' }, + { + id: 'cloudwatch-logs', + title: 'CloudWatch Logs', + description: 'Provide custom log groups for 3rd-party agents', + }, + ], + [] + ); // Build endpoint picker items: DEFAULT (plain) + each endpoint const endpointItems: SelectableItem[] = useMemo(() => { @@ -95,8 +114,11 @@ export function AddOnlineEvalScreen({ }, [agentNames]); const isNameStep = wizard.step === 'name'; + const isSourceStep = wizard.step === 'source'; const isAgentStep = wizard.step === 'agent'; const isEndpointStep = wizard.step === 'endpoint'; + const isLogGroupNamesStep = wizard.step === 'logGroupNames'; + const isServiceNameStep = wizard.step === 'serviceName'; const isEvaluatorsStep = wizard.step === 'evaluators'; const isSamplingRateStep = wizard.step === 'samplingRate'; const isEnableOnCreateStep = wizard.step === 'enableOnCreate'; @@ -110,6 +132,13 @@ export function AddOnlineEvalScreen({ [] ); + const sourceNav = useListNavigation({ + items: sourceItems, + onSelect: item => wizard.setSource(item.id as OnlineEvalSource), + onExit: () => wizard.goBack(), + isActive: isSourceStep, + }); + const agentNav = useListNavigation({ items: agentItems, onSelect: item => wizard.setAgent(item.id), @@ -152,7 +181,7 @@ export function AddOnlineEvalScreen({ const helpText = isEvaluatorsStep ? 'Space toggle · Enter confirm · Esc back' - : isAgentStep || isEndpointStep || isEnableOnCreateStep + : isSourceStep || isAgentStep || isEndpointStep || isEnableOnCreateStep ? HELP_TEXT.NAVIGATE_SELECT : isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL @@ -162,6 +191,26 @@ export function AddOnlineEvalScreen({ ); + // Build confirm fields based on source + const confirmFields = useMemo(() => { + const fields = [{ label: 'Name', value: effectiveConfig.name }]; + if (wizard.source === 'agentcore-runtime') { + fields.push({ label: 'Agent', value: effectiveConfig.agent }); + if (effectiveConfig.endpoint) { + fields.push({ label: 'Endpoint', value: effectiveConfig.endpoint }); + } + } else { + fields.push({ label: 'Log Groups', value: (effectiveConfig.logGroupNames ?? []).join(', ') }); + if (effectiveConfig.serviceNames && effectiveConfig.serviceNames.length > 0) { + fields.push({ label: 'Service Names', value: effectiveConfig.serviceNames.join(', ') }); + } + } + fields.push({ label: 'Evaluators', value: effectiveConfig.evaluators.join(', ') }); + fields.push({ label: 'Sampling Rate', value: `${effectiveConfig.samplingRate}%` }); + fields.push({ label: 'Enable on Deploy', value: effectiveConfig.enableOnCreate ? 'Yes' : 'No' }); + return fields; + }, [effectiveConfig, wizard.source]); + return ( @@ -177,6 +226,15 @@ export function AddOnlineEvalScreen({ /> )} + {isSourceStep && ( + + )} + {isAgentStep && ( )} + {isLogGroupNamesStep && ( + + + Enter CloudWatch log group names (1-5). Press Enter to add each name. Submit an empty value when done. + + {logGroupEntries.length > 0 && ( + + {logGroupEntries.map((entry, i) => ( + + {' '} + {i + 1}. {entry} + + ))} + + )} + { + if (value === '' && logGroupEntries.length > 0) { + // Empty submission finalizes the list + wizard.setLogGroupNames(logGroupEntries); + setLogGroupEntries([]); + } else if (value !== '') { + if (logGroupEntries.length >= 5) return; + setLogGroupEntries(prev => [...prev, value]); + } + }} + onCancel={() => wizard.goBack()} + customValidation={value => { + if (value === '' && logGroupEntries.length === 0) return 'At least one log group name is required'; + if (value === '' && logGroupEntries.length > 0) return true; // allow empty to finish + if (logGroupEntries.length >= 5) return 'Maximum 5 log group names allowed'; + return true; + }} + /> + + )} + + {isServiceNameStep && ( + + Enter service names separated by spaces (optional). Leave empty to skip. + { + const names = value.trim() ? value.trim().split(/\s+/) : []; + wizard.setServiceNames(names); + }} + onCancel={() => wizard.goBack()} + /> + + )} + {isEvaluatorsStep && ( { const rate = parseFloat(value); @@ -239,18 +353,7 @@ export function AddOnlineEvalScreen({ /> )} - {isConfirmStep && ( - - )} + {isConfirmStep && } ); diff --git a/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx b/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx index bfc0e7ed1..8dd5fb969 100644 --- a/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx +++ b/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx @@ -81,7 +81,7 @@ async function fetchDashboardConfigs(): Promise { name: local.name, configId: deployed?.onlineEvaluationConfigId ?? '', region, - evaluators: local.evaluators, + evaluators: local.evaluators ?? [], samplingRate: local.samplingRate, executionStatus: deployed?.executionStatus, }); diff --git a/src/cli/tui/screens/online-eval/types.ts b/src/cli/tui/screens/online-eval/types.ts index 1a1e5940c..0073c29d7 100644 --- a/src/cli/tui/screens/online-eval/types.ts +++ b/src/cli/tui/screens/online-eval/types.ts @@ -4,17 +4,24 @@ export type AddOnlineEvalStep = | 'name' + | 'source' | 'agent' | 'endpoint' + | 'logGroupNames' + | 'serviceName' | 'evaluators' | 'samplingRate' | 'enableOnCreate' | 'confirm'; +export type OnlineEvalSource = 'agentcore-runtime' | 'cloudwatch-logs'; + export interface AddOnlineEvalConfig { name: string; agent: string; endpoint?: string; + logGroupNames?: string[]; + serviceNames?: string[]; evaluators: string[]; samplingRate: number; enableOnCreate: boolean; @@ -29,8 +36,11 @@ export interface RuntimeEndpointEntry { export const ONLINE_EVAL_STEP_LABELS: Record = { name: 'Name', + source: 'Source', agent: 'Agent', endpoint: 'Endpoint', + logGroupNames: 'Log Groups', + serviceName: 'Services', evaluators: 'Evaluators', samplingRate: 'Rate', enableOnCreate: 'Enable', diff --git a/src/cli/tui/screens/online-eval/useAddOnlineEvalWizard.ts b/src/cli/tui/screens/online-eval/useAddOnlineEvalWizard.ts index 239a95edc..440629bea 100644 --- a/src/cli/tui/screens/online-eval/useAddOnlineEvalWizard.ts +++ b/src/cli/tui/screens/online-eval/useAddOnlineEvalWizard.ts @@ -1,13 +1,35 @@ -import type { AddOnlineEvalConfig, AddOnlineEvalStep } from './types'; +import type { AddOnlineEvalConfig, AddOnlineEvalStep, OnlineEvalSource } from './types'; import { DEFAULT_SAMPLING_RATE } from './types'; import { useCallback, useRef, useState } from 'react'; function getAllSteps(agentCount: number): AddOnlineEvalStep[] { if (agentCount <= 1) { // endpoint step is included but will be skipped dynamically when no endpoints exist - return ['name', 'endpoint', 'evaluators', 'samplingRate', 'enableOnCreate', 'confirm']; + // source step routes to either agent/endpoint OR logGroupNames/serviceName + return [ + 'name', + 'source', + 'endpoint', + 'logGroupNames', + 'serviceName', + 'evaluators', + 'samplingRate', + 'enableOnCreate', + 'confirm', + ]; } - return ['name', 'agent', 'endpoint', 'evaluators', 'samplingRate', 'enableOnCreate', 'confirm']; + return [ + 'name', + 'source', + 'agent', + 'endpoint', + 'logGroupNames', + 'serviceName', + 'evaluators', + 'samplingRate', + 'enableOnCreate', + 'confirm', + ]; } function getDefaultConfig(): AddOnlineEvalConfig { @@ -15,6 +37,8 @@ function getDefaultConfig(): AddOnlineEvalConfig { name: '', agent: '', endpoint: undefined, + logGroupNames: undefined, + serviceNames: undefined, evaluators: [], samplingRate: DEFAULT_SAMPLING_RATE, enableOnCreate: true, @@ -27,6 +51,7 @@ export function useAddOnlineEvalWizard(agentCount: number) { const allSteps = getAllSteps(agentCount); const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState(allSteps[0]!); + const [source, setSourceState] = useState('agentcore-runtime'); const skipCheckRef = useRef(() => false); const currentIndex = allSteps.indexOf(step); @@ -66,6 +91,21 @@ export function useAddOnlineEvalWizard(agentCount: number) { [nextStep, setConfig, setStep] ); + const setSource = useCallback( + (selectedSource: OnlineEvalSource) => { + setSourceState(selectedSource); + // Reset fields based on source selection + if (selectedSource === 'cloudwatch-logs') { + setConfig(c => ({ ...c, agent: '', endpoint: undefined, logGroupNames: undefined, serviceNames: undefined })); + } else { + setConfig(c => ({ ...c, logGroupNames: undefined, serviceNames: undefined })); + } + const next = nextStep('source'); + if (next) setStep(next); + }, + [nextStep, setSourceState, setConfig, setStep] + ); + const setAgent = useCallback( (agent: string) => { setConfig(c => ({ ...c, agent, endpoint: undefined })); @@ -84,6 +124,24 @@ export function useAddOnlineEvalWizard(agentCount: number) { [nextStep, setConfig, setStep] ); + const setLogGroupNames = useCallback( + (logGroupNames: string[]) => { + setConfig(c => ({ ...c, logGroupNames })); + const next = nextStep('logGroupNames'); + if (next) setStep(next); + }, + [nextStep, setConfig, setStep] + ); + + const setServiceNames = useCallback( + (serviceNames: string[]) => { + setConfig(c => ({ ...c, serviceNames: serviceNames.length > 0 ? serviceNames : undefined })); + const next = nextStep('serviceName'); + if (next) setStep(next); + }, + [nextStep, setConfig, setStep] + ); + const setEvaluators = useCallback( (evaluators: string[]) => { setConfig(c => ({ ...c, evaluators })); @@ -113,19 +171,24 @@ export function useAddOnlineEvalWizard(agentCount: number) { const reset = useCallback(() => { setConfig(getDefaultConfig()); + setSourceState('agentcore-runtime'); setStep(allSteps[0]!); - }, [allSteps, setConfig, setStep]); + }, [allSteps, setSourceState, setConfig, setStep]); return { config, step, steps: allSteps, currentIndex, + source, goBack, setSkipCheck, setName, + setSource, setAgent, setEndpoint, + setLogGroupNames, + setServiceNames, setEvaluators, setSamplingRate, setEnableOnCreate, diff --git a/src/cli/tui/screens/online-insights/AddOnlineInsightsFlow.tsx b/src/cli/tui/screens/online-insights/AddOnlineInsightsFlow.tsx new file mode 100644 index 000000000..ca31cb1d1 --- /dev/null +++ b/src/cli/tui/screens/online-insights/AddOnlineInsightsFlow.tsx @@ -0,0 +1,145 @@ +import { ConfigIO } from '../../../../lib'; +import { getErrorMessage } from '../../../errors'; +import { onlineInsightsPrimitive } from '../../../primitives/registry'; +import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; +import { ErrorPrompt, GradientText } from '../../components'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddOnlineInsightsScreen } from './AddOnlineInsightsScreen'; +import type { AddOnlineInsightsConfig } from './types'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'loading' } + | { name: 'create-wizard'; agentNames: string[] } + | { name: 'create-success'; configName: string } + | { name: 'error'; message: string }; + +interface AddOnlineInsightsFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function AddOnlineInsightsFlow({ + isInteractive = true, + onExit, + onBack, + onDev, + onDeploy, +}: AddOnlineInsightsFlowProps) { + const [flow, setFlow] = useState({ name: 'loading' }); + const [existingConfigNames, setExistingConfigNames] = useState([]); + + // Load project data + useEffect(() => { + if (flow.name !== 'loading') return; + let cancelled = false; + + void (async () => { + try { + const projectSpec = await new ConfigIO().readProjectSpec(); + if (cancelled) return; + + const runtimesList = projectSpec.runtimes ?? []; + const agentNames = runtimesList.map(a => a.name); + + if (agentNames.length === 0) { + setFlow({ + name: 'error', + message: 'No agents found in project. Add an agent first with `agentcore add agent`.', + }); + return; + } + + const names = await onlineInsightsPrimitive.getAllNames(); + if (cancelled) return; + setExistingConfigNames(names); + + setFlow({ name: 'create-wizard', agentNames }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [flow.name]); + + useEffect(() => { + if (!isInteractive && flow.name === 'create-success') { + onExit(); + } + }, [isInteractive, flow.name, onExit]); + + const handleCreateComplete = useCallback((config: AddOnlineInsightsConfig) => { + void (async () => { + try { + const addResult = await withCommandRunTelemetry( + 'add.online-insights', + { + insights_count: config.insights.length, + enable_on_create: config.enableOnCreate, + }, + () => + onlineInsightsPrimitive.add({ + name: config.name, + agent: config.agent, + insights: config.insights, + samplingRate: config.samplingRate, + clusteringFrequencies: config.clusteringFrequencies.length > 0 ? config.clusteringFrequencies : undefined, + enableOnCreate: config.enableOnCreate, + }) + ); + if (!addResult.success) { + throw new Error(addResult.error?.message ?? 'Failed to create online insights config'); + } + setFlow({ name: 'create-success', configName: config.name }); + } catch (err) { + setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + }, []); + + if (flow.name === 'loading') { + return ; + } + + if (flow.name === 'create-wizard') { + return ( + + ); + } + + if (flow.name === 'create-success') { + return ( + + ); + } + + return ( + { + setFlow({ name: 'loading' }); + }} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/online-insights/AddOnlineInsightsScreen.tsx b/src/cli/tui/screens/online-insights/AddOnlineInsightsScreen.tsx new file mode 100644 index 000000000..622fa2a52 --- /dev/null +++ b/src/cli/tui/screens/online-insights/AddOnlineInsightsScreen.tsx @@ -0,0 +1,216 @@ +import { OnlineEvalConfigNameSchema } from '../../../../schema'; +import type { SelectableItem } from '../../components'; +import { + ConfirmReview, + Panel, + Screen, + StepIndicator, + TextInput, + WizardMultiSelect, + WizardSelect, +} from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; +import { generateUniqueName } from '../../utils'; +import type { AddOnlineInsightsConfig } from './types'; +import { + AVAILABLE_INSIGHTS, + CLUSTERING_FREQUENCIES, + DEFAULT_INSIGHTS_SAMPLING_RATE, + ONLINE_INSIGHTS_STEP_LABELS, +} from './types'; +import { useAddOnlineInsightsWizard } from './useAddOnlineInsightsWizard'; +import { Box, Text } from 'ink'; +import React, { useMemo } from 'react'; + +interface AddOnlineInsightsScreenProps { + onComplete: (config: AddOnlineInsightsConfig) => void; + onExit: () => void; + existingConfigNames: string[]; + agentNames: string[]; +} + +export function AddOnlineInsightsScreen({ + onComplete, + onExit, + existingConfigNames, + agentNames, +}: AddOnlineInsightsScreenProps) { + const wizard = useAddOnlineInsightsWizard(); + + // Auto-set agent when there's only one + const effectiveConfig = useMemo(() => { + if (agentNames.length === 1 && !wizard.config.agent) { + return { ...wizard.config, agent: agentNames[0]! }; + } + return wizard.config; + }, [wizard.config, agentNames]); + + const isAgentStep = wizard.step === 'agent'; + const isInsightsStep = wizard.step === 'insights'; + const isSamplingRateStep = wizard.step === 'samplingRate'; + const isClusteringStep = wizard.step === 'clustering'; + const isNameStep = wizard.step === 'name'; + const isConfirmStep = wizard.step === 'confirm'; + + const agentItems: SelectableItem[] = useMemo(() => { + return agentNames.map(name => ({ id: name, title: name })); + }, [agentNames]); + + const insightItems: SelectableItem[] = useMemo(() => { + return AVAILABLE_INSIGHTS.map(i => ({ + id: i.id, + title: i.title, + description: i.description, + })); + }, []); + + const clusteringItems: SelectableItem[] = useMemo(() => { + return CLUSTERING_FREQUENCIES.map(f => ({ + id: f.id, + title: f.title, + description: `Cluster insights ${f.title.toLowerCase()}`, + })); + }, []); + + const agentNav = useListNavigation({ + items: agentItems, + onSelect: item => wizard.setAgent(item.id), + onExit: () => onExit(), + isActive: isAgentStep, + }); + + const insightsNav = useMultiSelectNavigation({ + items: insightItems, + getId: item => item.id, + onConfirm: ids => wizard.setInsights(ids), + onExit: () => wizard.goBack(), + isActive: isInsightsStep, + requireSelection: true, + }); + + const clusteringNav = useMultiSelectNavigation({ + items: clusteringItems, + getId: item => item.id, + onConfirm: ids => wizard.setClusteringFrequencies(ids), + onExit: () => wizard.goBack(), + isActive: isClusteringStep, + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(effectiveConfig), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + const helpText = + isInsightsStep || isClusteringStep + ? 'Space toggle · Enter confirm · Esc back' + : isAgentStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ( + + ); + + return ( + + + {isAgentStep && ( + + )} + + {isInsightsStep && ( + + )} + + {isSamplingRateStep && ( + + + Percentage of agent sessions to analyze. Higher rates give better coverage but increase costs. + + { + const filtered = value.replace(/[^0-9.]/g, ''); + if (filtered !== value) setValue(filtered); + }} + onSubmit={value => { + const rate = parseFloat(value); + if (isNaN(rate) || rate < 0.01 || rate > 100) return; + wizard.setSamplingRate(rate); + }} + onCancel={() => wizard.goBack()} + customValidation={value => { + const rate = parseFloat(value); + if (isNaN(rate)) return 'Must be a number'; + if (rate < 0.01 || rate > 100) return 'Must be between 0.01 and 100'; + return true; + }} + /> + + )} + + {isClusteringStep && ( + + )} + + {isNameStep && ( + wizard.goBack()} + schema={OnlineEvalConfigNameSchema} + customValidation={value => !existingConfigNames.includes(value) || 'Config name already exists'} + /> + )} + + {isConfirmStep && ( + i.split('.').pop()!).join(', ') }, + { label: 'Sampling Rate', value: `${effectiveConfig.samplingRate}%` }, + ...(effectiveConfig.clusteringFrequencies.length > 0 + ? [{ label: 'Clustering', value: effectiveConfig.clusteringFrequencies.join(', ') }] + : []), + { label: 'Enable on Deploy', value: 'Yes' }, + ]} + /> + )} + + + ); +} diff --git a/src/cli/tui/screens/online-insights/index.ts b/src/cli/tui/screens/online-insights/index.ts new file mode 100644 index 000000000..9c49ec844 --- /dev/null +++ b/src/cli/tui/screens/online-insights/index.ts @@ -0,0 +1,2 @@ +export { AddOnlineInsightsFlow } from './AddOnlineInsightsFlow'; +export { AddOnlineInsightsScreen } from './AddOnlineInsightsScreen'; diff --git a/src/cli/tui/screens/online-insights/types.ts b/src/cli/tui/screens/online-insights/types.ts new file mode 100644 index 000000000..87217987d --- /dev/null +++ b/src/cli/tui/screens/online-insights/types.ts @@ -0,0 +1,49 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Online Insights Config Flow Types +// ───────────────────────────────────────────────────────────────────────────── + +export type AddOnlineInsightsStep = 'agent' | 'insights' | 'samplingRate' | 'clustering' | 'name' | 'confirm'; + +export interface AddOnlineInsightsConfig { + name: string; + agent: string; + insights: string[]; + samplingRate: number; + clusteringFrequencies: string[]; + enableOnCreate: boolean; +} + +export const ONLINE_INSIGHTS_STEP_LABELS: Record = { + agent: 'Agent', + insights: 'Insights', + samplingRate: 'Rate', + clustering: 'Clustering', + name: 'Name', + confirm: 'Confirm', +}; + +export const DEFAULT_INSIGHTS_SAMPLING_RATE = 100; + +export const AVAILABLE_INSIGHTS = [ + { + id: 'Builtin.Insight.FailureAnalysis', + title: 'Failure Analysis', + description: 'Analyze failure patterns and root causes across sessions', + }, + { + id: 'Builtin.Insight.UserIntent', + title: 'User Intent', + description: 'Classify and cluster user intents from session transcripts', + }, + { + id: 'Builtin.Insight.ExecutionSummary', + title: 'Execution Summary', + description: 'Summarize execution patterns and tool usage across sessions', + }, +]; + +export const CLUSTERING_FREQUENCIES = [ + { id: 'DAILY', title: 'Daily' }, + { id: 'WEEKLY', title: 'Weekly' }, + { id: 'MONTHLY', title: 'Monthly' }, +]; diff --git a/src/cli/tui/screens/online-insights/useAddOnlineInsightsWizard.ts b/src/cli/tui/screens/online-insights/useAddOnlineInsightsWizard.ts new file mode 100644 index 000000000..6255663f7 --- /dev/null +++ b/src/cli/tui/screens/online-insights/useAddOnlineInsightsWizard.ts @@ -0,0 +1,106 @@ +import type { AddOnlineInsightsConfig, AddOnlineInsightsStep } from './types'; +import { DEFAULT_INSIGHTS_SAMPLING_RATE } from './types'; +import { useCallback, useState } from 'react'; + +const ALL_STEPS: AddOnlineInsightsStep[] = ['agent', 'insights', 'samplingRate', 'clustering', 'name', 'confirm']; + +function getDefaultConfig(): AddOnlineInsightsConfig { + return { + name: '', + agent: '', + insights: [], + samplingRate: DEFAULT_INSIGHTS_SAMPLING_RATE, + clusteringFrequencies: [], + enableOnCreate: true, + }; +} + +export function useAddOnlineInsightsWizard() { + const allSteps = ALL_STEPS; + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState(allSteps[0]!); + + const currentIndex = allSteps.indexOf(step); + + const nextStep = useCallback( + (currentStep: AddOnlineInsightsStep): AddOnlineInsightsStep | undefined => { + const idx = allSteps.indexOf(currentStep); + if (idx + 1 < allSteps.length) { + return allSteps[idx + 1]!; + } + return undefined; + }, + [allSteps] + ); + + const goBack = useCallback(() => { + for (let i = currentIndex - 1; i >= 0; i--) { + setStep(allSteps[i]!); + return; + } + }, [allSteps, currentIndex, setStep]); + + const setAgent = useCallback( + (agent: string) => { + setConfig(c => ({ ...c, agent })); + const next = nextStep('agent'); + if (next) setStep(next); + }, + [nextStep, setConfig, setStep] + ); + + const setInsights = useCallback( + (insights: string[]) => { + setConfig(c => ({ ...c, insights })); + const next = nextStep('insights'); + if (next) setStep(next); + }, + [nextStep, setConfig, setStep] + ); + + const setSamplingRate = useCallback( + (samplingRate: number) => { + setConfig(c => ({ ...c, samplingRate })); + const next = nextStep('samplingRate'); + if (next) setStep(next); + }, + [nextStep, setConfig, setStep] + ); + + const setClusteringFrequencies = useCallback( + (clusteringFrequencies: string[]) => { + setConfig(c => ({ ...c, clusteringFrequencies })); + const next = nextStep('clustering'); + if (next) setStep(next); + }, + [nextStep, setConfig, setStep] + ); + + const setName = useCallback( + (name: string) => { + setConfig(c => ({ ...c, name })); + const next = nextStep('name'); + if (next) setStep(next); + }, + [nextStep, setConfig, setStep] + ); + + const reset = useCallback(() => { + setConfig(getDefaultConfig()); + setStep(allSteps[0]!); + }, [allSteps, setConfig, setStep]); + + return { + config, + step, + steps: allSteps, + currentIndex, + goBack, + setAgent, + setInsights, + setSamplingRate, + setClusteringFrequencies, + setName, + reset, + }; +} diff --git a/src/cli/tui/screens/policy/AddPolicyFlow.tsx b/src/cli/tui/screens/policy/AddPolicyFlow.tsx index 0f6758e97..dcbaeff2e 100644 --- a/src/cli/tui/screens/policy/AddPolicyFlow.tsx +++ b/src/cli/tui/screens/policy/AddPolicyFlow.tsx @@ -17,7 +17,7 @@ import { AddSuccessScreen } from '../add/AddSuccessScreen'; import { POLICY_ENGINE_MODE_OPTIONS } from '../mcp/types'; import { AddPolicyEngineScreen } from './AddPolicyEngineScreen'; import { AddPolicyScreen } from './AddPolicyScreen'; -import type { AddPolicyConfig, AddPolicyEngineConfig } from './types'; +import { type AddPolicyConfig, type AddPolicyEngineConfig, authorizationPhaseForEffect } from './types'; import { Box, Text } from 'ink'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -40,6 +40,7 @@ type FlowState = preSelectedEngine: string; isEngineDeployed: boolean; deployedGateways: Record; + projectGateways: { name: string; httpTargets: string[] }[]; } | { name: 'engine-success'; engineName: string } | { name: 'policy-success'; policyName: string; engineName: string } @@ -58,6 +59,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD const [engineNames, setEngineNames] = useState([]); const [policyNames, setPolicyNames] = useState([]); const [hasUnprotectedGateways, setHasUnprotectedGateways] = useState(false); + const [policyAddInFlight, setPolicyAddInFlight] = useState(false); const [pendingEngineName, setPendingEngineName] = useState(); const engineSteps = useMemo(() => { @@ -88,11 +90,11 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD }; }, []); - // In non-interactive mode, exit after success + // In non-interactive mode, show success screen and let user dismiss with Esc/Ctrl+C useEffect(() => { if (!isInteractive) { if (flow.name === 'engine-success' || flow.name === 'policy-success') { - onExit(); + // Success screen renders with exit instructions — user presses Esc/Ctrl+C } } }, [isInteractive, flow.name, onExit]); @@ -116,15 +118,17 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD setFlow({ name: 'engine-wizard' }); } else { setFlow({ name: 'loading' }); - const [deployedId, deployedGateways] = await Promise.all([ + const [deployedId, deployedGateways, projectGateways] = await Promise.all([ policyEnginePrimitive.getDeployedEngineId(item.id), policyEnginePrimitive.getDeployedGateways(), + policyEnginePrimitive.getProjectGateways(), ]); setFlow({ name: 'policy-wizard', preSelectedEngine: item.id, isEngineDeployed: deployedId !== null && Object.keys(deployedGateways).length > 0, deployedGateways, + projectGateways, }); } }, []); @@ -163,46 +167,59 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD [commitEngine] ); - const handlePolicyComplete = useCallback(async (config: AddPolicyConfig) => { - const result = await withCommandRunTelemetry( - 'add.policy', - { - policy_attr_source_type: config.sourceFile - ? 'file' - : config.sourceMethod === 'generate' - ? 'generate' - : 'statement', - policy_validation_mode: standardize(PolicyValidationMode, config.validationMode ?? 'FAIL_ON_ANY_FINDINGS'), - }, - () => - policyPrimitive.add({ - name: config.name, - engine: config.engine, - statement: config.statement, - source: config.sourceFile || undefined, - validationMode: config.validationMode, - }) - ); + const handlePolicyComplete = useCallback( + async (config: AddPolicyConfig) => { + if (policyAddInFlight) return; + setPolicyAddInFlight(true); + const result = await withCommandRunTelemetry( + 'add.policy', + { + policy_attr_source_type: config.sourceFile + ? 'file' + : config.sourceMethod === 'generate' + ? 'generate' + : 'statement', + policy_validation_mode: standardize(PolicyValidationMode, config.validationMode ?? 'FAIL_ON_ANY_FINDINGS'), + }, + () => + policyPrimitive.add({ + name: config.name, + engine: config.engine, + statement: config.statement, + source: config.sourceFile || undefined, + validationMode: config.validationMode, + enforcementMode: config.enforcementMode, + // Output-phase effects (suppressOutput) must register on RETURN_OUTPUT. The + // effect is only known for the form source; other sources stay on INITIATE. + authorizationPhase: + config.sourceMethod === 'form' ? authorizationPhaseForEffect(config.guardrailForm.effect) : 'INITIATE', + }) + ); - if (result.success) { - setPolicyNames(prev => [...prev, config.name]); - setFlow({ name: 'policy-success', policyName: config.name, engineName: config.engine }); - } else { - setFlow({ name: 'error', message: result.error.message }); - } - }, []); + if (result.success) { + setPolicyNames(prev => [...prev, config.name]); + setFlow({ name: 'policy-success', policyName: config.name, engineName: config.engine }); + } else { + setPolicyAddInFlight(false); + setFlow({ name: 'error', message: result.error.message }); + } + }, + [policyAddInFlight] + ); const handleAddPolicyToNewEngine = useCallback(async (engineName: string) => { setFlow({ name: 'loading' }); - const [deployedId, deployedGateways] = await Promise.all([ + const [deployedId, deployedGateways, projectGateways] = await Promise.all([ policyEnginePrimitive.getDeployedEngineId(engineName), policyEnginePrimitive.getDeployedGateways(), + policyEnginePrimitive.getProjectGateways(), ]); setFlow({ name: 'policy-wizard', preSelectedEngine: engineName, isEngineDeployed: deployedId !== null && Object.keys(deployedGateways).length > 0, deployedGateways, + projectGateways, }); }, []); @@ -255,6 +272,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD preSelectedEngine={flow.preSelectedEngine} isEngineDeployed={flow.isEngineDeployed} deployedGateways={flow.deployedGateways} + projectGateways={flow.projectGateways} onComplete={(config: AddPolicyConfig) => void handlePolicyComplete(config)} onExit={() => setFlow({ name: 'select' })} /> @@ -361,7 +379,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD agentcore/agentcore.json{' '} - Cedar policy added to engine {flow.engineName} + Policy added to engine {flow.engineName} diff --git a/src/cli/tui/screens/policy/AddPolicyScreen.tsx b/src/cli/tui/screens/policy/AddPolicyScreen.tsx index 38005228d..13ee435f6 100644 --- a/src/cli/tui/screens/policy/AddPolicyScreen.tsx +++ b/src/cli/tui/screens/policy/AddPolicyScreen.tsx @@ -1,14 +1,33 @@ import { PolicyNameSchema } from '../../../../schema'; import { detectRegion } from '../../../aws'; import { getPolicyGeneration, startPolicyGeneration } from '../../../aws/policy-generation'; +import { isGatedFeaturesEnabled } from '../../../feature-flags'; import { policyEnginePrimitive } from '../../../primitives/registry'; -import { ConfirmReview, Panel, PathInput, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; +import { + ConfirmReview, + Panel, + PathInput, + Screen, + StepIndicator, + TextInput, + WizardMultiSelect, + WizardSelect, +} from '../../components'; import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; -import { useListNavigation } from '../../hooks'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; -import type { AddPolicyConfig, PolicySourceMethod } from './types'; -import { POLICY_SOURCE_METHOD_OPTIONS, POLICY_STEP_LABELS, VALIDATION_MODE_OPTIONS } from './types'; +import { synthesizeCedar } from './synthesize-cedar'; +import type { AddPolicyConfig, GuardrailCategoryType, PolicyEffect, PolicySourceMethod } from './types'; +import { + ENFORCEMENT_MODE_OPTIONS, + GUARDRAIL_CATEGORY_OPTIONS, + POLICY_EFFECT_OPTIONS, + POLICY_SOURCE_METHOD_OPTIONS, + POLICY_STEP_LABELS, + VALIDATION_MODE_OPTIONS, + defaultDataPathForEffect, +} from './types'; import { useAddPolicyWizard } from './useAddPolicyWizard'; import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; @@ -22,6 +41,8 @@ interface AddPolicyScreenProps { preSelectedEngine?: string; isEngineDeployed?: boolean; deployedGateways?: Record; + /** Gateways from agentcore.json with their mcpServer target names */ + projectGateways?: { name: string; httpTargets: string[] }[]; } export function AddPolicyScreen({ @@ -32,8 +53,9 @@ export function AddPolicyScreen({ preSelectedEngine, isEngineDeployed = false, deployedGateways = {}, + projectGateways = [], }: AddPolicyScreenProps) { - const wizard = useAddPolicyWizard(preSelectedEngine); + const wizard = useAddPolicyWizard(preSelectedEngine, Object.keys(deployedGateways).length > 0); // Generation state const [generatedPolicy, setGeneratedPolicy] = useState(null); @@ -54,11 +76,13 @@ export function AddPolicyScreen({ () => POLICY_SOURCE_METHOD_OPTIONS.map(opt => { const isGenerate = opt.id === 'generate'; - const disabled = isGenerate && !isEngineDeployed; + // Guardrail form is gated behind ENABLE_GATED_FEATURES. + const gated = opt.id === 'form' && !isGatedFeaturesEnabled(); + const disabled = gated || (isGenerate && !isEngineDeployed); return { id: opt.id, title: opt.title, - description: disabled ? 'Deploy engine first' : opt.description, + description: gated ? 'Coming soon' : disabled ? 'Deploy engine first' : opt.description, disabled, }; }), @@ -83,6 +107,7 @@ export function AddPolicyScreen({ const isFirstStep = wizard.currentIndex === 0; const goBackOrExit = isFirstStep ? onExit : () => wizard.goBack(); + const isGatewaySelectStep = wizard.step === 'gateway'; const isEngineStep = wizard.step === 'engine'; const isNameStep = wizard.step === 'name'; const isSourceMethodStep = wizard.step === 'source-method'; @@ -92,9 +117,52 @@ export function AddPolicyScreen({ const isGenerateDescriptionStep = wizard.step === 'source-generate-description'; const isGenerateLoadingStep = wizard.step === 'source-generate-loading'; const isGenerateReviewStep = wizard.step === 'source-generate-review'; + const isFormCategoryStep = wizard.step === 'source-form-category'; + const isFormFiltersStep = wizard.step === 'source-form-filters'; + const isFormDataPathStep = wizard.step === 'source-form-data-path'; + const isFormEffectStep = wizard.step === 'source-form-effect'; + const isFormReviewStep = wizard.step === 'source-form-review'; const isValidationStep = wizard.step === 'validation-mode'; + const isEnforcementStep = wizard.step === 'enforcement-mode'; const isConfirmStep = wizard.step === 'confirm'; + // ─── Standard navigation hooks ──────────────────────────────────────────────── + + const hasGateways = Object.keys(deployedGateways).length > 0; + + const deployedGatewayItems: SelectableItem[] = useMemo( + () => + Object.entries(deployedGateways).map(([name, arn]) => ({ + id: name, + title: name, + description: arn.split(':').slice(-1)[0], + })), + [deployedGateways] + ); + + const gatewaySelectNav = useListNavigation({ + items: deployedGatewayItems, + onSelect: item => wizard.setGatewayForPolicy(item.id), + onExit: goBackOrExit, + isActive: isGatewaySelectStep && hasGateways, + }); + + // Target items based on selected gateway + const isTargetStep = wizard.step === 'target'; + + const targetItems: SelectableItem[] = useMemo(() => { + const gw = projectGateways.find(g => g.name === wizard.config.gatewayName); + if (!gw) return []; + return gw.httpTargets.map(t => ({ id: t, title: t, description: 'HTTP runtime target' })); + }, [projectGateways, wizard.config.gatewayName]); + + const targetNav = useListNavigation({ + items: targetItems, + onSelect: item => wizard.setTargetForPolicy(item.id), + onExit: goBackOrExit, + isActive: isTargetStep, + }); + const engineNav = useListNavigation({ items: engineItems, onSelect: item => wizard.setEngine(item.id), @@ -126,6 +194,18 @@ export function AddPolicyScreen({ isActive: isValidationStep, }); + const enforcementModeItems = useMemo( + () => ENFORCEMENT_MODE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const enforcementNav = useListNavigation({ + items: enforcementModeItems, + onSelect: item => wizard.setEnforcementMode(item.id), + onExit: goBackOrExit, + isActive: isEnforcementStep, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(wizard.config), @@ -133,7 +213,101 @@ export function AddPolicyScreen({ isActive: isConfirmStep, }); - // Handle generation review: accept or go back + // ─── Form mode: Category select ────────────────────────────────────────────── + + const categoryItems: SelectableItem[] = useMemo( + () => + GUARDRAIL_CATEGORY_OPTIONS.map(opt => ({ + id: opt.id, + title: opt.title, + description: opt.description, + })), + [] + ); + + const categoryNav = useListNavigation({ + items: categoryItems, + onSelect: item => wizard.setFormCategory(item.id as GuardrailCategoryType), + onExit: goBackOrExit, + isActive: isFormCategoryStep, + }); + + // ─── Form mode: Effect select (permit/forbid) ────────────────────────────── + + const effectItems: SelectableItem[] = useMemo( + () => POLICY_EFFECT_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const effectNav = useListNavigation({ + items: effectItems, + onSelect: item => wizard.setFormEffect(item.id as PolicyEffect), + onExit: goBackOrExit, + isActive: isFormEffectStep, + }); + + // ─── Form mode: Filter multi-select ─────────────────────────────────────────── + + const filterItems: SelectableItem[] = useMemo(() => { + const cat = wizard.config.guardrailForm.category; + if (!cat) return []; + const opt = GUARDRAIL_CATEGORY_OPTIONS.find(o => o.id === cat); + if (!opt) return []; + return opt.filters.map(f => ({ id: f, title: f })); + }, [wizard.config.guardrailForm.category]); + + const filterNav = useMultiSelectNavigation({ + items: filterItems, + getId: item => item.id, + onConfirm: ids => { + if (ids.length > 0) { + wizard.setFormFilters(ids); + } + }, + onExit: goBackOrExit, + isActive: isFormFiltersStep, + requireSelection: true, + }); + + // ─── Form mode: Review ──────────────────────────────────────────────────────── + + const formCedar = useMemo(() => { + if (!isFormReviewStep) return ''; + return synthesizeCedar(wizard.config.guardrailForm, { + targetName: wizard.config.targetName ?? undefined, + gatewayArn: deployedGateways[wizard.config.gatewayName] ?? undefined, + }); + }, [ + isFormReviewStep, + wizard.config.guardrailForm, + wizard.config.targetName, + wizard.config.gatewayName, + deployedGateways, + ]); + + const formReviewItems: SelectableItem[] = useMemo( + () => [ + { id: 'accept', title: 'Accept policy', description: 'Use this generated policy' }, + { id: 'edit', title: 'Edit selections', description: 'Go back and change filters/thresholds' }, + ], + [] + ); + + const formReviewNav = useListNavigation({ + items: formReviewItems, + onSelect: item => { + if (item.id === 'accept') { + wizard.acceptFormReview(formCedar); + } else { + wizard.goBack(); + } + }, + onExit: goBackOrExit, + isActive: isFormReviewStep, + }); + + // ─── Generate mode: Review ──────────────────────────────────────────────────── + const reviewItems: SelectableItem[] = useMemo( () => [ { id: 'accept', title: 'Accept generated policy', description: 'Use this policy' }, @@ -168,14 +342,12 @@ export function AddPolicyScreen({ isActive: isGenerateReviewStep && !generationError, }); - // Real policy generation when entering the loading step + // ─── Generate mode: Loading effect ──────────────────────────────────────────── + useEffect(() => { if (!isGenerateLoadingStep) return undefined; if (skipGeneration.current) { skipGeneration.current = false; - // Navigate back past the loading step to the description step. - // This runs after React re-rendered with the loading step active, - // so goBack() correctly sees 'source-generate-loading' as current step. wizard.goBack(); return undefined; } @@ -186,8 +358,6 @@ export function AddPolicyScreen({ try { const regionResult = await detectRegion(); const region = regionResult.region; - - // policyEngineId is needed; get it from deployed state const policyEngineId = await policyEnginePrimitive.getDeployedEngineId(wizard.config.engine); if (!policyEngineId) { @@ -227,7 +397,6 @@ export function AddPolicyScreen({ } void generate(); - return () => { cancelled = true; }; @@ -239,32 +408,75 @@ export function AddPolicyScreen({ wizard, ]); - // Determine help text + // ─── Help text ──────────────────────────────────────────────────────────────── + const helpText: string = - isEngineStep || isSourceMethodStep || isValidationStep || isGenerateReviewStep || isGatewayStep + isEngineStep || + isSourceMethodStep || + isValidationStep || + isGenerateReviewStep || + isGatewayStep || + isFormCategoryStep || + isFormReviewStep || + isFormEffectStep || + isGatewaySelectStep || + isTargetStep || + isEnforcementStep ? HELP_TEXT.NAVIGATE_SELECT - : isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : isGenerateLoadingStep - ? HELP_TEXT.BACK - : HELP_TEXT.TEXT_INPUT; + : isFormFiltersStep + ? 'Space toggle · Enter confirm · Esc back' + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : isGenerateLoadingStep + ? HELP_TEXT.BACK + : HELP_TEXT.TEXT_INPUT; const headerContent = ; const validationModeLabel = wizard.config.validationMode === 'FAIL_ON_ANY_FINDINGS' ? 'Fail on any findings' : 'Ignore all findings'; - // Determine the cedar source display for confirm screen const cedarSourceDisplay = wizard.config.sourceMethod === 'file' ? wizard.config.sourceFile : wizard.config.sourceMethod === 'generate' ? `Generated from: "${wizard.config.naturalLanguageDescription}"` - : '(inline statement)'; + : wizard.config.sourceMethod === 'form' + ? `Form: ${wizard.config.guardrailForm.category} (${wizard.config.guardrailForm.filters.length} filters)` + : '(inline statement)'; + + // ─── Render ─────────────────────────────────────────────────────────────────── return ( + {isGatewaySelectStep && hasGateways && ( + + )} + + {isGatewaySelectStep && !hasGateways && ( + + No deployed gateways found. + + Run `agentcore deploy` to deploy a gateway first. + + + )} + + {isTargetStep && ( + + )} + {isEngineStep && ( @@ -306,7 +518,7 @@ export function AddPolicyScreen({ {isSourceInlineStep && ( - Generating Cedar policy from description... + Generating policy from description... “{wizard.config.naturalLanguageDescription}” @@ -356,7 +568,7 @@ export function AddPolicyScreen({ {isGenerateReviewStep && generatedPolicy && !generationError && ( - Generated Cedar policy: + Generated policy: {generatedPolicy.split('\n').map((line, i) => ( @@ -373,10 +585,81 @@ export function AddPolicyScreen({ )} + {isFormEffectStep && ( + + )} + + {isFormCategoryStep && ( + + )} + + {isFormFiltersStep && ( + + )} + + {isFormDataPathStep && ( + + )} + + {isFormReviewStep && ( + + Generated policy from guardrail form: + + {formCedar.split('\n').map((line, i) => ( + + {line} + + ))} + + + Authorization phase: INITIATE (default) + + + + )} + + {isEnforcementStep && ( + + )} + {isValidationStep && ( @@ -387,7 +670,8 @@ export function AddPolicyScreen({ fields={[ { label: 'Engine', value: wizard.config.engine }, { label: 'Name', value: wizard.config.name }, - { label: 'Cedar source', value: cedarSourceDisplay }, + { label: 'Policy source', value: cedarSourceDisplay }, + { label: 'Enforcement', value: wizard.config.enforcementMode === 'ACTIVE' ? 'Active' : 'Log only' }, { label: 'Validation', value: validationModeLabel }, ]} /> diff --git a/src/cli/tui/screens/policy/__tests__/synthesize-cedar.test.ts b/src/cli/tui/screens/policy/__tests__/synthesize-cedar.test.ts new file mode 100644 index 000000000..3f4324f2c --- /dev/null +++ b/src/cli/tui/screens/policy/__tests__/synthesize-cedar.test.ts @@ -0,0 +1,128 @@ +import { synthesizeCedar } from '../synthesize-cedar.js'; +import type { GuardrailFormConfig } from '../types.js'; +import { describe, expect, it } from 'vitest'; + +describe('synthesizeCedar', () => { + const baseForm: GuardrailFormConfig = { + category: 'contentFilter', + filters: ['VIOLENCE'], + effect: 'forbid', + dataPath: 'context.input.prompt', + }; + + it('returns comment when no category is set', () => { + const form: GuardrailFormConfig = { category: null, filters: [], effect: 'forbid', dataPath: '' }; + expect(synthesizeCedar(form)).toBe('// No guardrail rules configured'); + }); + + it('returns comment when filters are empty', () => { + const form: GuardrailFormConfig = { category: 'contentFilter', filters: [], effect: 'forbid', dataPath: '' }; + expect(synthesizeCedar(form)).toBe('// No guardrail rules configured'); + }); + + it('generates forbid policy with single content filter', () => { + const result = synthesizeCedar(baseForm); + expect(result).toContain('forbid (principal, action, resource is AgentCore::Gateway)'); + expect(result).toContain('BedrockGuardrails::ContentFilter'); + expect(result).toContain('["VIOLENCE"]'); + expect(result).toContain('["VIOLENCE"].confidenceScore'); + expect(result).toContain('.greaterThan(decimal("0.2"))'); + expect(result).toContain('[context.input.prompt]'); + }); + + it('generates permit policy with single filter using lessThanOrEqual', () => { + const form: GuardrailFormConfig = { ...baseForm, effect: 'permit' }; + const result = synthesizeCedar(form); + expect(result).toContain('permit (principal, action, resource is AgentCore::Gateway)'); + expect(result).toContain('.lessThanOrEqual(decimal("0.2"))'); + }); + + it('uses maxConfidenceScore for multiple filters', () => { + const form: GuardrailFormConfig = { ...baseForm, filters: ['VIOLENCE', 'HATE'] }; + const result = synthesizeCedar(form); + expect(result).toContain('["VIOLENCE", "HATE"]'); + expect(result).toContain('.maxConfidenceScore().greaterThan(decimal("0.2"))'); + expect(result).not.toContain('confidenceScore'); + }); + + it('uses promptAttack function and threshold', () => { + const form: GuardrailFormConfig = { + category: 'promptAttack', + filters: ['JAILBREAK'], + effect: 'forbid', + dataPath: 'context.input.prompt', + }; + const result = synthesizeCedar(form); + expect(result).toContain('BedrockGuardrails::PromptAttack'); + expect(result).toContain('.greaterThan(decimal("0.4"))'); + }); + + it('uses sensitiveInformation function and threshold', () => { + const form: GuardrailFormConfig = { + category: 'sensitiveInformation', + filters: ['EMAIL', 'PHONE'], + effect: 'forbid', + dataPath: 'context.input.prompt', + }; + const result = synthesizeCedar(form); + expect(result).toContain('BedrockGuardrails::SensitiveInformation'); + expect(result).toContain('["EMAIL", "PHONE"]'); + expect(result).toContain('.maxConfidenceScore().greaterThan(decimal("0.2"))'); + }); + + it('includes targetName in action reference when provided', () => { + const result = synthesizeCedar(baseForm, { targetName: 'my-target' }); + expect(result).toContain('action == AgentCore::Action::"my-target___POST:/invocations"'); + }); + + it('includes gatewayArn in resource reference when provided', () => { + const result = synthesizeCedar(baseForm, { gatewayArn: 'arn:aws:agentcore:us-east-1:123456:gateway/gw-123' }); + expect(result).toContain('resource == AgentCore::Gateway::"arn:aws:agentcore:us-east-1:123456:gateway/gw-123"'); + }); + + it('includes both targetName and gatewayArn when provided', () => { + const result = synthesizeCedar(baseForm, { + targetName: 'prod', + gatewayArn: 'arn:aws:agentcore:us-east-1:123456:gateway/gw-abc', + }); + expect(result).toContain('action == AgentCore::Action::"prod___POST:/invocations"'); + expect(result).toContain('resource == AgentCore::Gateway::"arn:aws:agentcore:us-east-1:123456:gateway/gw-abc"'); + }); + + it('uses custom dataPath', () => { + const form: GuardrailFormConfig = { ...baseForm, dataPath: 'context.output.response' }; + const result = synthesizeCedar(form); + expect(result).toContain('[context.output.response]'); + }); + + it('generates suppressOutput policy using greaterThan and an output data path by default', () => { + const form: GuardrailFormConfig = { ...baseForm, effect: 'suppressOutput', dataPath: '' }; + const result = synthesizeCedar(form); + expect(result).toContain('suppressOutput (principal, action, resource is AgentCore::Gateway)'); + expect(result).toContain('.greaterThan(decimal("0.2"))'); + // suppressOutput evaluates the model response, so it defaults to context.output.* + expect(result).toContain('[context.output.prompt]'); + expect(result).not.toContain('[context.input.prompt]'); + }); + + it('respects an explicit dataPath for suppressOutput', () => { + const form: GuardrailFormConfig = { + ...baseForm, + effect: 'suppressOutput', + dataPath: 'context.output.response', + }; + const result = synthesizeCedar(form); + expect(result).toContain('[context.output.response]'); + }); + + it('uses maxConfidenceScore + greaterThan for multi-filter suppressOutput', () => { + const form: GuardrailFormConfig = { + ...baseForm, + effect: 'suppressOutput', + filters: ['VIOLENCE', 'HATE'], + dataPath: '', + }; + const result = synthesizeCedar(form); + expect(result).toContain('.maxConfidenceScore().greaterThan(decimal("0.2"))'); + }); +}); diff --git a/src/cli/tui/screens/policy/__tests__/useAddPolicyWizard.render.test.tsx b/src/cli/tui/screens/policy/__tests__/useAddPolicyWizard.render.test.tsx new file mode 100644 index 000000000..456e42c51 --- /dev/null +++ b/src/cli/tui/screens/policy/__tests__/useAddPolicyWizard.render.test.tsx @@ -0,0 +1,76 @@ +// Render-level tests for the add-policy wizard hook. These mount the real hook +// and assert its live step state — specifically that with no deployed gateways +// the wizard opens on a usable step (never the "No deployed gateways" dead-end) +// and can advance through the policy steps without ever hitting gateway/target. +import type { AddPolicyStep } from '../types'; +import { useAddPolicyWizard } from '../useAddPolicyWizard'; +import { Text } from 'ink'; +import { render } from 'ink-testing-library'; +import React, { act, useImperativeHandle } from 'react'; +import { describe, expect, it } from 'vitest'; + +type WizardReturn = ReturnType; + +interface HarnessHandle { + wizard: WizardReturn; +} + +interface HarnessProps { + preSelectedEngine?: string; + hasDeployedGateways: boolean; +} + +const Harness = React.forwardRef(({ preSelectedEngine, hasDeployedGateways }, ref) => { + const wizard = useAddPolicyWizard(preSelectedEngine, hasDeployedGateways); + useImperativeHandle(ref, () => ({ wizard }), [wizard]); + return ( + + step:{wizard.step} steps:{wizard.steps.join(',')} + + ); +}); +Harness.displayName = 'Harness'; + +function mount(props: HarnessProps) { + const ref = React.createRef(); + const { lastFrame } = render(); + return { ref, lastFrame }; +} + +describe('useAddPolicyWizard — gateway/target skipping', () => { + it('opens on a non-gateway step when no gateway is deployed', () => { + const { ref, lastFrame } = mount({ hasDeployedGateways: false }); + // Must NOT open on the gateway step (the dead-end when nothing is deployed). + expect(ref.current!.wizard.step).not.toBe('gateway'); + expect(ref.current!.wizard.step).toBe('engine'); + expect(ref.current!.wizard.steps).not.toContain('gateway'); + expect(ref.current!.wizard.steps).not.toContain('target'); + expect(lastFrame()).toContain('step:engine'); + }); + + it('opens on the gateway step when a gateway is deployed', () => { + const { ref } = mount({ hasDeployedGateways: true }); + expect(ref.current!.wizard.step).toBe('gateway'); + expect(ref.current!.wizard.steps).toContain('gateway'); + expect(ref.current!.wizard.steps).toContain('target'); + }); + + it('advances engine -> name -> source-method without touching gateway/target when none deployed', () => { + const { ref } = mount({ hasDeployedGateways: false }); + expect(ref.current!.wizard.step).toBe('engine'); + + act(() => ref.current!.wizard.setEngine('eng')); + expect(ref.current!.wizard.step).toBe('name'); + + act(() => ref.current!.wizard.setName('p1')); + expect(ref.current!.wizard.step).toBe('source-method'); + + // Picking the inline source advances into its step — no gateway prompt in between. + act(() => ref.current!.wizard.setSourceMethod('inline')); + expect(ref.current!.wizard.step).toBe('source-inline'); + + const seen: AddPolicyStep[] = ref.current!.wizard.steps; + expect(seen).not.toContain('gateway'); + expect(seen).not.toContain('target'); + }); +}); diff --git a/src/cli/tui/screens/policy/synthesize-cedar.ts b/src/cli/tui/screens/policy/synthesize-cedar.ts new file mode 100644 index 000000000..c2c5da381 --- /dev/null +++ b/src/cli/tui/screens/policy/synthesize-cedar.ts @@ -0,0 +1,58 @@ +import { type GuardrailCategoryType, type GuardrailFormConfig, defaultDataPathForEffect } from './types'; + +const GUARDRAIL_FUNCTION_MAP: Record = { + contentFilter: 'BedrockGuardrails::ContentFilter', + promptAttack: 'BedrockGuardrails::PromptAttack', + sensitiveInformation: 'BedrockGuardrails::SensitiveInformation', +}; + +// Default thresholds per category type +const DEFAULT_THRESHOLDS: Record = { + contentFilter: 0.2, + promptAttack: 0.4, + sensitiveInformation: 0.2, +}; + +/** + * Synthesize a Cedar policy from a guardrail form config. + * + * Single filter: ...["FILTER"].confidenceScore.greaterThan(decimal("0.4")) + * Multiple filters (forbid/suppressOutput): ...maxConfidenceScore().greaterThan(decimal("0.4")) + * Multiple filters (permit): ...maxConfidenceScore().lessThanOrEqual(decimal("0.4")) + * + * `suppressOutput` is an output-phase forbid: it evaluates `context.output.*` and + * blocks the model response when the score exceeds the threshold (greaterThan), + * so it shares forbid's comparator but defaults to an output data path. + */ +export interface SynthesizeCedarOptions { + targetName?: string; + gatewayArn?: string; +} + +export function synthesizeCedar(form: GuardrailFormConfig, options: SynthesizeCedarOptions = {}): string { + if (!form.category || form.filters.length === 0) { + return '// No guardrail rules configured'; + } + + const { targetName, gatewayArn } = options; + const fn = GUARDRAIL_FUNCTION_MAP[form.category]; + const gwRef = gatewayArn ? `resource == AgentCore::Gateway::"${gatewayArn}"` : 'resource is AgentCore::Gateway'; + const actionRef = targetName ? `action == AgentCore::Action::"${targetName}___POST:/invocations"` : 'action'; + const dataPath = form.dataPath || defaultDataPathForEffect(form.effect); + const threshold = DEFAULT_THRESHOLDS[form.category]; + // permit allows below threshold; forbid and suppressOutput block above it. + const comparator = form.effect === 'permit' ? 'lessThanOrEqual' : 'greaterThan'; + const filterList = `[${form.filters.map(f => `"${f}"`).join(', ')}]`; + + let scoreExpr: string; + if (form.filters.length === 1) { + scoreExpr = `["${form.filters[0]}"].confidenceScore`; + } else { + scoreExpr = '.maxConfidenceScore()'; + } + + return ( + `${form.effect} (principal, ${actionRef}, ${gwRef})\n` + + `when guardrails { ${fn}(${filterList}, [${dataPath}])${scoreExpr}.${comparator}(decimal("${threshold.toFixed(1)}")) };` + ); +} diff --git a/src/cli/tui/screens/policy/types.ts b/src/cli/tui/screens/policy/types.ts index 8d7066e7b..b1bceaf5b 100644 --- a/src/cli/tui/screens/policy/types.ts +++ b/src/cli/tui/screens/policy/types.ts @@ -4,9 +4,129 @@ export type PolicyResourceType = 'policy-engine' | 'policy'; -export type PolicySourceMethod = 'file' | 'inline' | 'generate'; +export type PolicySourceMethod = 'file' | 'inline' | 'generate' | 'form'; + +// ───────────────────────────────────────────────────────────────────────────── +// Guardrail Model (matches KobaPolicyEvaluator Smithy model) +// ───────────────────────────────────────────────────────────────────────────── + +export type GuardrailCategoryType = 'contentFilter' | 'promptAttack' | 'sensitiveInformation'; + +export const CONTENT_FILTER_FILTERS = ['VIOLENCE', 'HATE', 'SEXUAL', 'MISCONDUCT', 'INSULT'] as const; +export type ContentFilterCategory = (typeof CONTENT_FILTER_FILTERS)[number]; + +export const PROMPT_ATTACK_FILTERS = ['JAILBREAK', 'PROMPT_INJECTION', 'PROMPT_LEAKAGE'] as const; +export type PromptAttackCategory = (typeof PROMPT_ATTACK_FILTERS)[number]; + +export const SENSITIVE_INFO_FILTERS = [ + 'ADDRESS', + 'AGE', + 'AWS_ACCESS_KEY', + 'AWS_SECRET_KEY', + 'CA_HEALTH_NUMBER', + 'CA_SOCIAL_INSURANCE_NUMBER', + 'CREDIT_DEBIT_CARD_CVV', + 'CREDIT_DEBIT_CARD_EXPIRY', + 'CREDIT_DEBIT_CARD_NUMBER', + 'DRIVER_ID', + 'EMAIL', + 'INTERNATIONAL_BANK_ACCOUNT_NUMBER', + 'IP_ADDRESS', + 'LICENSE_PLATE', + 'MAC_ADDRESS', + 'NAME', + 'PASSWORD', + 'PHONE', + 'PIN', + 'SWIFT_CODE', + 'UK_NATIONAL_HEALTH_SERVICE_NUMBER', + 'UK_NATIONAL_INSURANCE_NUMBER', + 'UK_UNIQUE_TAXPAYER_REFERENCE_NUMBER', + 'URL', + 'USERNAME', + 'US_BANK_ACCOUNT_NUMBER', + 'US_BANK_ROUTING_NUMBER', + 'US_INDIVIDUAL_TAX_IDENTIFICATION_NUMBER', + 'US_PASSPORT_NUMBER', + 'US_SOCIAL_SECURITY_NUMBER', + 'VEHICLE_IDENTIFICATION_NUMBER', +] as const; +export type SensitiveInformationEntityType = (typeof SENSITIVE_INFO_FILTERS)[number]; + +export type GuardrailFilter = ContentFilterCategory | PromptAttackCategory | SensitiveInformationEntityType; + +export const GUARDRAIL_CATEGORY_OPTIONS: { + id: GuardrailCategoryType; + title: string; + description: string; + filters: readonly string[]; +}[] = [ + { + id: 'contentFilter', + title: 'Content Filter', + description: 'Violence, hate, sexual, misconduct, insults', + filters: CONTENT_FILTER_FILTERS, + }, + { + id: 'promptAttack', + title: 'Prompt Attack', + description: 'Jailbreak, injection, leakage', + filters: PROMPT_ATTACK_FILTERS, + }, + { + id: 'sensitiveInformation', + title: 'Sensitive Information', + description: 'PII & credentials detection', + filters: SENSITIVE_INFO_FILTERS, + }, +]; + +export type PolicyEffect = 'permit' | 'forbid' | 'suppressOutput'; + +/** + * `permit`/`forbid` evaluate at request time (INITIATE phase) against input data. + * `suppressOutput` is an output-phase effect: it evaluates the model's response + * (RETURN_OUTPUT phase) against `context.output.*` and blocks the response when a + * guardrail trips. The Koba registry infers the phase from the effect keyword and + * rejects input data paths for `suppressOutput`. + */ +export const POLICY_EFFECT_OPTIONS: { id: PolicyEffect; title: string; description: string }[] = [ + { id: 'forbid', title: 'Forbid', description: 'Block requests that exceed threshold (greaterThan)' }, + { id: 'permit', title: 'Permit', description: 'Allow requests that fall below threshold (lessThan)' }, + { + id: 'suppressOutput', + title: 'Suppress Output', + description: "Block the model's response when output exceeds threshold (greaterThan)", + }, +]; + +/** Effects that evaluate the model output (RETURN_OUTPUT phase) rather than the request. */ +export const OUTPUT_PHASE_EFFECTS: readonly PolicyEffect[] = ['suppressOutput']; + +export const DEFAULT_INPUT_DATA_PATH = 'context.input.prompt'; +export const DEFAULT_OUTPUT_DATA_PATH = 'context.output.prompt'; + +/** The authorization phase a given effect must be registered under. */ +export function authorizationPhaseForEffect(effect: PolicyEffect): 'INITIATE' | 'RETURN_OUTPUT' { + return OUTPUT_PHASE_EFFECTS.includes(effect) ? 'RETURN_OUTPUT' : 'INITIATE'; +} + +/** The default data path to suggest for a given effect. */ +export function defaultDataPathForEffect(effect: PolicyEffect): string { + return OUTPUT_PHASE_EFFECTS.includes(effect) ? DEFAULT_OUTPUT_DATA_PATH : DEFAULT_INPUT_DATA_PATH; +} + +/** Form config: selected category, chosen filters, effect, and data path */ +export interface GuardrailFormConfig { + category: GuardrailCategoryType | null; + filters: string[]; + effect: PolicyEffect; + dataPath: string; +} export type AddPolicyStep = + | 'gateway' + | 'target' | 'engine' | 'name' | 'source-method' @@ -16,6 +136,12 @@ export type AddPolicyStep = | 'source-generate-description' | 'source-generate-loading' | 'source-generate-review' + | 'source-form-effect' + | 'source-form-category' + | 'source-form-filters' + | 'source-form-data-path' + | 'source-form-review' + | 'enforcement-mode' | 'validation-mode' | 'confirm'; @@ -29,9 +155,13 @@ export interface AddPolicyConfig { sourceMethod: PolicySourceMethod; statement: string; sourceFile: string; + gatewayName: string; + targetName: string; gatewayArn: string; naturalLanguageDescription: string; validationMode: 'FAIL_ON_ANY_FINDINGS' | 'IGNORE_ALL_FINDINGS'; + enforcementMode: 'ACTIVE' | 'LOG_ONLY'; + guardrailForm: GuardrailFormConfig; } // ───────────────────────────────────────────────────────────────────────────── @@ -39,15 +169,23 @@ export interface AddPolicyConfig { // ───────────────────────────────────────────────────────────────────────────── export const POLICY_STEP_LABELS: Record = { + gateway: 'Gateway', + target: 'Target', engine: 'Engine', name: 'Name', 'source-method': 'Source', 'source-file': 'File', - 'source-inline': 'Cedar', + 'source-inline': 'Policy', 'source-generate-gateway': 'Gateway', 'source-generate-description': 'Describe', 'source-generate-loading': 'Generating', 'source-generate-review': 'Review', + 'source-form-effect': 'Effect', + 'source-form-category': 'Category', + 'source-form-filters': 'Filters', + 'source-form-data-path': 'Data Path', + 'source-form-review': 'Review', + 'enforcement-mode': 'Enforcement', 'validation-mode': 'Validation', confirm: 'Confirm', }; @@ -56,29 +194,47 @@ export const VALIDATION_MODE_OPTIONS = [ { id: 'FAIL_ON_ANY_FINDINGS', title: 'Fail on any findings', - description: 'Block policies that fail Cedar analyzer validation', + description: 'Block policies that fail analyzer validation', }, { id: 'IGNORE_ALL_FINDINGS', title: 'Ignore all findings', - description: 'Skip Cedar analyzer validation checks', + description: 'Skip analyzer validation checks', + }, +] as const; + +export const ENFORCEMENT_MODE_OPTIONS = [ + { + id: 'ACTIVE', + title: 'Active', + description: 'Policy decisions are enforced on requests', + }, + { + id: 'LOG_ONLY', + title: 'Log only', + description: 'Policy is evaluated but decisions are observed only (shadow mode)', }, ] as const; export const POLICY_SOURCE_METHOD_OPTIONS = [ + { + id: 'form' as const, + title: 'Use a form', + description: 'Guardrail categories, filters & thresholds', + }, { id: 'file' as const, - title: 'Select a Cedar policy file', + title: 'Select a policy file', description: 'From your project', }, { id: 'inline' as const, - title: 'Write a Cedar policy', - description: 'Type Cedar directly', + title: 'Write a policy', + description: 'Type policy directly', }, { id: 'generate' as const, - title: 'Generate a Cedar policy', + title: 'Generate a policy', description: 'From natural language', }, ] as const; @@ -92,6 +248,6 @@ export const POLICY_RESOURCE_OPTIONS = [ { id: 'policy' as const, title: 'Policy', - description: 'Cedar policy within an engine', + description: 'Policy within an engine', }, ] as const; diff --git a/src/cli/tui/screens/policy/useAddPolicyWizard.ts b/src/cli/tui/screens/policy/useAddPolicyWizard.ts index 69664083f..4973383e5 100644 --- a/src/cli/tui/screens/policy/useAddPolicyWizard.ts +++ b/src/cli/tui/screens/policy/useAddPolicyWizard.ts @@ -1,11 +1,18 @@ -import type { AddPolicyConfig, AddPolicyStep, PolicySourceMethod } from './types'; +import type { AddPolicyConfig, AddPolicyStep, GuardrailCategoryType, PolicyEffect, PolicySourceMethod } from './types'; import { useCallback, useState } from 'react'; // Steps vary based on source method, but the wizard tracks the current step directly -const COMMON_PREFIX: AddPolicyStep[] = ['engine', 'name', 'source-method']; -const COMMON_SUFFIX: AddPolicyStep[] = ['validation-mode', 'confirm']; +const COMMON_PREFIX: AddPolicyStep[] = ['gateway', 'target', 'engine', 'name', 'source-method']; +const COMMON_SUFFIX: AddPolicyStep[] = ['enforcement-mode', 'validation-mode', 'confirm']; const SOURCE_STEPS: Record = { + form: [ + 'source-form-effect', + 'source-form-category', + 'source-form-filters', + 'source-form-data-path', + 'source-form-review', + ], file: ['source-file'], inline: ['source-inline'], generate: [ @@ -16,8 +23,21 @@ const SOURCE_STEPS: Record = { ], }; -function getSteps(sourceMethod: PolicySourceMethod | null, skipEngine: boolean): AddPolicyStep[] { - const prefix = skipEngine ? COMMON_PREFIX.filter(s => s !== 'engine') : COMMON_PREFIX; +function getSteps( + sourceMethod: PolicySourceMethod | null, + skipEngine: boolean, + hasDeployedGateways: boolean +): AddPolicyStep[] { + // The gateway/target steps scope the policy to a deployed gateway. With no + // deployed gateways there is nothing to pick, so skip them (the policy is + // still valid — just not gateway-scoped) instead of dead-ending the wizard. + const skip = new Set(); + if (skipEngine) skip.add('engine'); + if (!hasDeployedGateways) { + skip.add('gateway'); + skip.add('target'); + } + const prefix = COMMON_PREFIX.filter(s => !skip.has(s)); const sourceSteps = sourceMethod ? SOURCE_STEPS[sourceMethod] : []; return [...prefix, ...sourceSteps, ...COMMON_SUFFIX]; } @@ -29,43 +49,64 @@ function getDefaultConfig(preSelectedEngine?: string): AddPolicyConfig { sourceMethod: 'file', statement: '', sourceFile: '', + gatewayName: '', + targetName: '', gatewayArn: '', naturalLanguageDescription: '', validationMode: 'FAIL_ON_ANY_FINDINGS', + enforcementMode: 'ACTIVE', + guardrailForm: { category: null, filters: [], effect: 'forbid', dataPath: '' }, }; } -export function useAddPolicyWizard(preSelectedEngine?: string) { +export function useAddPolicyWizard(preSelectedEngine?: string, hasDeployedGateways = true) { const skipEngine = !!preSelectedEngine; const [config, setConfig] = useState(() => getDefaultConfig(preSelectedEngine)); - const initialStep: AddPolicyStep = skipEngine ? 'name' : 'engine'; + // Start on the first step that survives skipping (gateway/target are dropped + // when nothing is deployed), so the wizard never opens on a dead-end screen. + const initialStep: AddPolicyStep = getSteps(null, skipEngine, hasDeployedGateways)[0]!; const [step, setStep] = useState(initialStep); const [sourceMethod, setSourceMethodState] = useState(null); - const steps = getSteps(sourceMethod, skipEngine); + const steps = getSteps(sourceMethod, skipEngine, hasDeployedGateways); const currentIndex = steps.indexOf(step); const goBack = useCallback(() => { - const allSteps = getSteps(sourceMethod, skipEngine); + const allSteps = getSteps(sourceMethod, skipEngine, hasDeployedGateways); const idx = allSteps.indexOf(step); if (idx > 0) { const prevStep = allSteps[idx - 1]!; - // If going back from a source sub-step to source-method, clear the source method if (prevStep === 'source-method') { setSourceMethodState(null); } setStep(prevStep); } - }, [sourceMethod, step, skipEngine]); + }, [sourceMethod, step, skipEngine, hasDeployedGateways]); const advance = useCallback( (fromStep: AddPolicyStep) => { - const allSteps = getSteps(sourceMethod, skipEngine); + const allSteps = getSteps(sourceMethod, skipEngine, hasDeployedGateways); const idx = allSteps.indexOf(fromStep); const next = allSteps[idx + 1]; if (next) setStep(next); }, - [sourceMethod, skipEngine] + [sourceMethod, skipEngine, hasDeployedGateways] + ); + + const setGatewayForPolicy = useCallback( + (gatewayName: string) => { + setConfig(c => ({ ...c, gatewayName })); + advance('gateway'); + }, + [advance] + ); + + const setTargetForPolicy = useCallback( + (targetName: string) => { + setConfig(c => ({ ...c, targetName })); + advance('target'); + }, + [advance] ); const setEngine = useCallback( @@ -88,13 +129,12 @@ export function useAddPolicyWizard(preSelectedEngine?: string) { (method: PolicySourceMethod) => { setSourceMethodState(method); setConfig(c => ({ ...c, sourceMethod: method })); - // Compute next step with the new source method - const allSteps = getSteps(method, skipEngine); + const allSteps = getSteps(method, skipEngine, hasDeployedGateways); const idx = allSteps.indexOf('source-method'); const next = allSteps[idx + 1]; if (next) setStep(next); }, - [skipEngine] + [skipEngine, hasDeployedGateways] ); const setSourceFile = useCallback( @@ -137,7 +177,6 @@ export function useAddPolicyWizard(preSelectedEngine?: string) { [advance] ); - // Called when generation completes to move past the loading step const onGenerationComplete = useCallback( (statement: string) => { setConfig(c => ({ ...c, statement, sourceFile: '' })); @@ -154,6 +193,60 @@ export function useAddPolicyWizard(preSelectedEngine?: string) { [advance] ); + // Enforcement mode: ACTIVE or LOG_ONLY + const setEnforcementMode = useCallback( + (enforcementMode: AddPolicyConfig['enforcementMode']) => { + setConfig(c => ({ ...c, enforcementMode })); + advance('enforcement-mode'); + }, + [advance] + ); + + // Form mode: set effect (permit/forbid) + const setFormEffect = useCallback( + (effect: PolicyEffect) => { + setConfig(c => ({ ...c, guardrailForm: { ...c.guardrailForm, effect } })); + advance('source-form-effect'); + }, + [advance] + ); + + // Form mode: set category + const setFormCategory = useCallback( + (category: GuardrailCategoryType) => { + setConfig(c => ({ ...c, guardrailForm: { ...c.guardrailForm, category, filters: [] } })); + advance('source-form-category'); + }, + [advance] + ); + + // Form mode: set filters (multi-select within the chosen category) + const setFormFilters = useCallback( + (filters: string[]) => { + setConfig(c => ({ ...c, guardrailForm: { ...c.guardrailForm, filters } })); + advance('source-form-filters'); + }, + [advance] + ); + + // Form mode: set data path + const setFormDataPath = useCallback( + (dataPath: string) => { + setConfig(c => ({ ...c, guardrailForm: { ...c.guardrailForm, dataPath } })); + advance('source-form-data-path'); + }, + [advance] + ); + + // Form mode: accept review (store synthesized Cedar) + const acceptFormReview = useCallback( + (statement: string) => { + setConfig(c => ({ ...c, statement, sourceFile: '' })); + advance('source-form-review'); + }, + [advance] + ); + const reset = useCallback(() => { setConfig(getDefaultConfig(preSelectedEngine)); setStep(initialStep); @@ -166,6 +259,8 @@ export function useAddPolicyWizard(preSelectedEngine?: string) { steps, currentIndex, goBack, + setGatewayForPolicy, + setTargetForPolicy, setEngine, setName, setSourceMethod, @@ -176,6 +271,12 @@ export function useAddPolicyWizard(preSelectedEngine?: string) { setGeneratedStatement, onGenerationComplete, setValidationMode, + setEnforcementMode, + setFormEffect, + setFormCategory, + setFormFilters, + setFormDataPath, + acceptFormReview, reset, }; } diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index b044d5464..a55d9ca67 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -4,11 +4,9 @@ import { validateAwsCredentials } from '../../../aws/account'; import { listEvaluators } from '../../../aws/agentcore-control'; import { detectRegion } from '../../../aws/region'; import { getErrorMessage } from '../../../errors'; -import { applyRecommendationToBundle, runRecommendationCommand } from '../../../operations/recommendation'; -import type { RunRecommendationCommandResult } from '../../../operations/recommendation'; -import { saveRecommendationRun } from '../../../operations/recommendation/recommendation-storage'; -import { ErrorPrompt, GradientText, Panel, Screen, StepProgress } from '../../components'; -import type { Step } from '../../components'; +import { createJobEngine } from '../../../operations/jobs'; +import type { RecommendationJobRecord } from '../../../operations/jobs'; +import { ErrorPrompt, GradientText, Panel, Screen } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { RecommendationScreen } from './RecommendationScreen'; @@ -20,33 +18,24 @@ import type { RecommendationWizardConfig, } from './types'; import { Box, Text } from 'ink'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; type FlowState = | { name: 'loading' } | { name: 'wizard'; agents: AgentItem[]; evaluators: EvaluatorItem[]; configBundles: ConfigBundleItem[] } - | { - name: 'running'; - config: RecommendationWizardConfig; - steps: Step[]; - elapsed: number; - recommendationId?: string; - region?: string; - } - | { - name: 'results'; - result: Extract; - config: RecommendationWizardConfig; - filePath?: string; - } + | { name: 'starting'; config: RecommendationWizardConfig } + | { name: 'started'; record: RecommendationJobRecord; config: RecommendationWizardConfig } | { name: 'creds-error'; message: string } | { name: 'error'; message: string; logFilePath?: string }; interface RecommendationFlowProps { onExit: () => void; + /** Navigate to the Recommendation Jobs screen (falls back to onExit when not provided). */ + onViewJobs?: () => void; } -export function RecommendationFlow({ onExit }: RecommendationFlowProps) { +export function RecommendationFlow({ onExit, onViewJobs }: RecommendationFlowProps) { + const engine = useMemo(() => createJobEngine(new ConfigIO()), []); const [flow, setFlow] = useState({ name: 'loading' }); // Load agents and evaluators @@ -101,44 +90,19 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { }, [flow.name]); const handleRunComplete = useCallback((config: RecommendationWizardConfig) => { - const willFetchSpans = config.traceSource === 'sessions'; - - const initialSteps: Step[] = [ - ...(willFetchSpans ? [{ label: 'Fetching session spans from CloudWatch...', status: 'pending' as const }] : []), - { label: 'Starting recommendation...', status: 'running' }, - { label: 'Polling for results', status: 'pending' }, - { label: 'Saving results', status: 'pending' }, - ]; - - // If auto-fetching, the first step is active - if (willFetchSpans) { - initialSteps[0] = { ...initialSteps[0]!, status: 'running' }; - initialSteps[1] = { ...initialSteps[1]!, status: 'pending' }; - } - - setFlow({ name: 'running', config, steps: initialSteps, elapsed: 0 }); + setFlow({ name: 'starting', config }); }, []); - // Execute the recommendation when entering 'running' state + // Fire-and-forget: start the recommendation job, then show the Started confirmation screen. useEffect(() => { - if (flow.name !== 'running') return; + if (flow.name !== 'starting') return; let cancelled = false; const { config } = flow; - const startTime = Date.now(); - - const timer = setInterval(() => { - if (!cancelled) { - setFlow(prev => { - if (prev.name !== 'running') return prev; - return { ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) }; - }); - } - }, 1000); void (async () => { try { - const result = await runRecommendationCommand({ + const result = await engine.start('recommendation', { type: config.type, agent: config.agent, evaluators: config.evaluators, @@ -164,104 +128,24 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { traceSource: config.traceSource, lookbackDays: config.days, sessionIds: config.sessionIds.length > 0 ? config.sessionIds : undefined, - onProgress: (status, _message) => { - if (cancelled) return; - const hasFetchStep = config.traceSource === 'sessions'; - const offset = hasFetchStep ? 1 : 0; - - setFlow(prev => { - if (prev.name !== 'running') return prev; - const steps = [...prev.steps]; - if (status === 'fetching-spans') { - steps[0] = { ...steps[0]!, status: 'running' }; - } else if (status === 'starting') { - if (hasFetchStep) steps[0] = { ...steps[0]!, status: 'success' }; - steps[offset] = { ...steps[offset]!, status: 'running' }; - } else if (status === 'started' || status === 'polling') { - steps[offset] = { ...steps[offset]!, status: 'success' }; - steps[offset + 1] = { ...steps[offset + 1]!, status: 'running' }; - } - return { ...prev, steps }; - }); - }, - onStarted: info => { - setFlow(prev => { - if (prev.name !== 'running') return prev; - return { ...prev, recommendationId: info.recommendationId, region: info.region }; - }); - }, + kmsKeyArn: config.kmsKeyArn || undefined, }); - clearInterval(timer); if (cancelled) return; if (!result.success) { - setFlow(prev => { - if (prev.name !== 'running') return prev; - const steps = prev.steps.map(s => - s.status === 'running' ? { ...s, status: 'error' as const, error: result.error.message } : s - ); - return { ...prev, steps }; - }); - await new Promise(resolve => setTimeout(resolve, 2000)); - if (cancelled) return; - setFlow({ - name: 'error', - message: result.error?.message ?? 'Recommendation failed', - logFilePath: result.logFilePath, - }); + setFlow({ name: 'error', message: result.error.message }); return; } - // Mark polling success, saving running - const hasFetchStep = config.traceSource === 'sessions'; - const offset = hasFetchStep ? 1 : 0; - - setFlow(prev => { - if (prev.name !== 'running') return prev; - const steps = [...prev.steps]; - steps[offset + 1] = { ...steps[offset + 1]!, status: 'success' }; - steps[offset + 2] = { ...steps[offset + 2]!, status: 'running' }; - return { ...prev, steps }; - }); - - // Save results locally - let filePath: string | undefined; - try { - if (result.recommendationId) { - filePath = saveRecommendationRun( - result.recommendationId, - result, - config.type, - config.agent, - config.evaluators - ); - } - } catch { - // Non-fatal - } - - setFlow({ name: 'results', result, config, filePath }); + setFlow({ name: 'started', record: result.record, config }); } catch (err) { - clearInterval(timer); - if (!cancelled) { - const errorMsg = getErrorMessage(err); - setFlow(prev => { - if (prev.name !== 'running') return prev; - const steps = prev.steps.map(s => - s.status === 'running' ? { ...s, status: 'error' as const, error: errorMsg } : s - ); - return { ...prev, steps }; - }); - await new Promise(resolve => setTimeout(resolve, 2000)); - setFlow({ name: 'error', message: errorMsg }); - } + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); } })(); return () => { cancelled = true; - clearInterval(timer); }; }, [flow.name]); // eslint-disable-line react-hooks/exhaustive-deps @@ -269,7 +153,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { if (flow.name === 'loading') { return ( - + ); @@ -291,37 +175,21 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { ); } - if (flow.name === 'running') { - const minutes = Math.floor(flow.elapsed / 60); - const seconds = flow.elapsed % 60; - const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; - + if (flow.name === 'starting') { return ( - - - - - Agent: {flow.config.agent} - {' '} - Evaluator(s):{' '} - {flow.config.evaluators.map(e => (e.includes('/') ? e.split('/').pop()! : e)).join(', ')} - {' '} - ({timeStr}) - - - - + + ); } - if (flow.name === 'results') { + if (flow.name === 'started') { return ( - setFlow({ name: 'loading' })} + onViewJobs={onViewJobs} onExit={onExit} /> ); @@ -338,57 +206,28 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { } // ───────────────────────────────────────────────────────────────────────────── -// Results view +// Started confirmation view // ───────────────────────────────────────────────────────────────────────────── -interface ResultsViewProps { - result: Extract; +interface StartedViewProps { + record: RecommendationJobRecord; config: RecommendationWizardConfig; - filePath?: string; onRunAnother: () => void; + onViewJobs?: () => void; onExit: () => void; } -function ResultsView({ result, config, filePath, onRunAnother, onExit }: ResultsViewProps) { - const [applyStatus, setApplyStatus] = useState<{ applied: boolean; message: string } | null>(null); - - const isConfigBundle = config.inputSource === 'config-bundle' && config.bundleName; - const hasNewVersion = - !!result.result?.systemPromptRecommendationResult?.configurationBundle || - !!result.result?.toolDescriptionRecommendationResult?.configurationBundle; - const canApply = isConfigBundle && hasNewVersion && result.region && !applyStatus; - +function StartedView({ record, config, onRunAnother, onViewJobs, onExit }: StartedViewProps) { const actions = [ - ...(canApply ? [{ id: 'apply', title: 'Sync new bundle version to local config' }] : []), - { id: 'another', title: 'Run another recommendation' }, + { id: 'jobs', title: 'View jobs' }, + { id: 'another', title: 'Run another' }, { id: 'back', title: 'Back' }, ]; - const handleApply = useCallback(async () => { - if (!result.result || !result.region) return; - try { - const applyResult = await applyRecommendationToBundle({ - bundleArn: config.bundleName, // TUI stores ARN in bundleName - result: result.result, - region: result.region, - }); - if (applyResult.success) { - setApplyStatus({ - applied: true, - message: `New bundle version (${applyResult.newVersionId}) created with recommended changes. Local config updated.`, - }); - } else { - setApplyStatus({ applied: false, message: applyResult.error?.message ?? 'Unknown error' }); - } - } catch (err) { - setApplyStatus({ applied: false, message: getErrorMessage(err) }); - } - }, [result, config]); - const nav = useListNavigation({ items: actions, onSelect: item => { - if (item.id === 'apply') void handleApply(); + if (item.id === 'jobs') (onViewJobs ?? onExit)(); else if (item.id === 'another') onRunAnother(); else onExit(); }, @@ -396,75 +235,26 @@ function ResultsView({ result, config, filePath, onRunAnother, onExit }: Results isActive: true, }); - const sysResult = result.result?.systemPromptRecommendationResult; - const toolResult = result.result?.toolDescriptionRecommendationResult; - return ( - + - ✓ Recommendation complete + ✓ Recommendation submitted - ID: {result.recommendationId} + ID: {record.id} {' '} + Status: {record.status} + + Agent: {config.agent} - {sysResult && ( - - {sysResult.recommendedSystemPrompt && ( - - - Recommended System Prompt: - - - {sysResult.recommendedSystemPrompt} - - - )} - - )} - - {toolResult?.tools && toolResult.tools.length > 0 && ( - - - Recommended Tool Descriptions: - - {toolResult.tools.map(tool => ( - - {tool.toolName} - {tool.recommendedToolDescription} - - ))} - - )} - - {!sysResult && !toolResult && ( - - No recommendation results returned. - - )} - - {filePath && ( - - Results saved to: {filePath} - - )} - - {applyStatus && ( - - {applyStatus.applied ? ( - ✓ {applyStatus.message} - ) : ( - Could not sync: {applyStatus.message} - )} - - )} + + + When it completes, view it in Recommendation Jobs — the new config bundle (if any) will be applied to + agentcore.json automatically. + + {actions.map((action, idx) => { diff --git a/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx index e1ee7e9d9..b782202dd 100644 --- a/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationHistoryScreen.tsx @@ -1,10 +1,14 @@ -import type { RecommendationRunRecord } from '../../../operations/recommendation/recommendation-storage'; -import { listAllRecommendations } from '../../../operations/recommendation/recommendation-storage'; -import { Panel, Screen } from '../../components'; +import { ConfigIO } from '../../../../lib'; +import { validateAwsCredentials } from '../../../aws/account'; +import { getErrorMessage } from '../../../errors'; +import { createJobEngine } from '../../../operations/jobs'; +import type { RecommendationJobRecord } from '../../../operations/jobs'; +import { ErrorPrompt, Panel, Screen } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; -import { Box, Text, useInput, useStdout } from 'ink'; -import React, { useMemo, useState } from 'react'; +import { RecommendationDetailView, shortTypeName, statusColor } from '../job-detail'; +import { Box, Text, useStdout } from 'ink'; +import React, { useEffect, useMemo, useState } from 'react'; const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; @@ -19,19 +23,6 @@ function formatShortDate(timestamp: string): string { return `${mon} ${day} ${h12}:${m} ${ampm}`; } -function shortTypeName(type: string): string { - if (type === 'SYSTEM_PROMPT_RECOMMENDATION') return 'System Prompt'; - if (type === 'TOOL_DESCRIPTION_RECOMMENDATION') return 'Tool Description'; - return type; -} - -function statusColor(status: string): string { - if (status === 'COMPLETED' || status === 'SUCCEEDED') return 'green'; - if (status === 'FAILED') return 'red'; - if (status === 'IN_PROGRESS' || status === 'PENDING') return 'yellow'; - return 'gray'; -} - const CHROME_LINES = 9; // ───────────────────────────────────────────────────────────────────────────── @@ -44,8 +35,8 @@ function RecommendationListView({ onExit, availableHeight, }: { - records: RecommendationRunRecord[]; - onSelect: (record: RecommendationRunRecord) => void; + records: RecommendationJobRecord[]; + onSelect: (record: RecommendationJobRecord) => void; onExit: () => void; availableHeight: number; }) { @@ -68,7 +59,7 @@ function RecommendationListView({ return ( - Recommendation History + Recommendation Jobs {records.length} recommendation{records.length !== 1 ? 's' : ''} @@ -76,14 +67,14 @@ function RecommendationListView({ {visible.items.map((rec, vIdx) => { const idx = visible.startIdx + vIdx; const selected = idx === nav.selectedIndex; - const date = rec.startedAt ? formatShortDate(rec.startedAt) : 'unknown'; + const date = rec.createdAt ? formatShortDate(rec.createdAt) : 'unknown'; return ( - + {selected ? '❯' : ' '} {date.padEnd(16)} {rec.status.padEnd(12)} - {shortTypeName(rec.type).padEnd(18)} + {shortTypeName(rec.recommendationType).padEnd(18)} {rec.agent} ); @@ -96,135 +87,78 @@ function RecommendationListView({ ); } - -// ───────────────────────────────────────────────────────────────────────────── -// Detail view -// ───────────────────────────────────────────────────────────────────────────── - -function RecommendationDetailView({ record, onBack }: { record: RecommendationRunRecord; onBack: () => void }) { - useInput((input, key) => { - if (key.escape || input === 'b') { - onBack(); - } - }); - - const sysResult = record.result?.systemPromptRecommendationResult; - const toolResult = record.result?.toolDescriptionRecommendationResult; - - return ( - - - - ID: {record.recommendationId} - - - Type: {shortTypeName(record.type)} - {' '} - Agent: {record.agent} - {' '} - Status: {record.status} - - - Evaluators: {record.evaluators.join(', ')} - - {record.startedAt && ( - - Started: {new Date(record.startedAt).toLocaleString()} - - )} - {record.completedAt && ( - - Completed: {new Date(record.completedAt).toLocaleString()} - - )} - - {sysResult && ( - - {sysResult.recommendedSystemPrompt && ( - - - Recommended System Prompt: - - - {sysResult.recommendedSystemPrompt} - - - )} - - )} - - {toolResult?.tools && toolResult.tools.length > 0 && ( - - - Recommended Tool Descriptions: - - {toolResult.tools.map(tool => ( - - {tool.toolName} - {tool.recommendedToolDescription} - - ))} - - )} - - {!sysResult && !toolResult && ( - - No recommendation results available. - - )} - - - Press Esc or B to go back - - - - ); -} - // ───────────────────────────────────────────────────────────────────────────── // Main screen // ───────────────────────────────────────────────────────────────────────────── +type FlowState = + | { name: 'loading' } + | { name: 'creds-error'; message: string } + | { name: 'error'; message: string } + | { name: 'loaded'; records: RecommendationJobRecord[] }; + interface RecommendationHistoryScreenProps { onExit: () => void; } export function RecommendationHistoryScreen({ onExit }: RecommendationHistoryScreenProps) { + const engine = useMemo(() => createJobEngine(new ConfigIO()), []); const { stdout } = useStdout(); const terminalHeight = stdout?.rows ?? 24; const availableHeight = Math.max(6, terminalHeight - CHROME_LINES); - const [selectedRecord, setSelectedRecord] = useState(null); - - const [records, loaded, error] = useMemo(() => { - try { - return [listAllRecommendations(), true, null] as const; - } catch (err) { - return [[] as RecommendationRunRecord[], true, err instanceof Error ? err.message : String(err)] as const; - } - }, []); - - if (!loaded) { + const [flow, setFlow] = useState({ name: 'loading' }); + const [selectedRecord, setSelectedRecord] = useState(null); + + useEffect(() => { + let cancelled = false; + + void (async () => { + try { + await validateAwsCredentials(); + } catch (err) { + if (!cancelled) setFlow({ name: 'creds-error', message: getErrorMessage(err) }); + return; + } + + try { + const records = await engine.list({ type: 'recommendation' }); + if (!cancelled) setFlow({ name: 'loaded', records }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [engine]); + + if (flow.name === 'loading') { return ( - - Loading... + + Loading recommendation jobs... ); } - if (error) { + if (flow.name === 'creds-error') { + return ; + } + + if (flow.name === 'error') { return ( - - {error} + + {flow.message} ); } - if (records.length === 0) { + if (flow.records.length === 0) { return ( - + - No recommendation runs found. + No recommendation jobs found. Run `agentcore run recommendation` to create one. @@ -234,12 +168,12 @@ export function RecommendationHistoryScreen({ onExit }: RecommendationHistoryScr const helpText = selectedRecord ? 'Esc/B back to list' : HELP_TEXT.NAVIGATE_SELECT; return ( - + {selectedRecord ? ( setSelectedRecord(null)} /> ) : ( )} + {isKmsKeyArnStep && ( + wizard.goBack()} + customValidation={value => { + if (!value) return true; + if (!isValidKmsKeyArn(value)) { + return 'Invalid KMS key ARN (e.g. arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012)'; + } + return true; + }} + /> + )} + {isConfirmStep && } diff --git a/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx index 2e53e0ebd..e510f4806 100644 --- a/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationsHubScreen.tsx @@ -21,8 +21,8 @@ export function RecommendationsHubScreen({ onSelect, onExit }: RecommendationsHu }, { id: 'recommendation-history', - title: 'Recommendation History', - description: 'View past recommendation results (local)', + title: 'Recommendation Jobs', + description: 'View recommendation jobs and their results', }, ], [] @@ -36,7 +36,7 @@ export function RecommendationsHubScreen({ onSelect, onExit }: RecommendationsHu }); return ( - + ); diff --git a/src/cli/tui/screens/recommendation/types.ts b/src/cli/tui/screens/recommendation/types.ts index 587ea4a20..e70d0ba22 100644 --- a/src/cli/tui/screens/recommendation/types.ts +++ b/src/cli/tui/screens/recommendation/types.ts @@ -1,8 +1,8 @@ import type { - RecommendationInputSourceKind, + RecommendationInputSource as RecommendationInputSourceKind, RecommendationType, - TraceSourceKind, -} from '../../../operations/recommendation'; + RecommendationTraceSource as TraceSourceKind, +} from '../../../operations/jobs'; export type RecommendationStep = | 'type' @@ -16,6 +16,7 @@ export type RecommendationStep = | 'traceSource' | 'days' | 'sessions' + | 'kms-key-arn' | 'confirm'; export interface RecommendationWizardConfig { @@ -35,6 +36,8 @@ export interface RecommendationWizardConfig { systemPromptJsonPath: string; /** Tool name → JSONPath pairs for tool descriptions within the config bundle */ toolDescJsonPaths: { toolName: string; toolDescriptionJsonPath: string }[]; + /** KMS key ARN for encrypting recommendation results */ + kmsKeyArn: string; } export const RECOMMENDATION_STEP_LABELS: Record = { @@ -49,6 +52,7 @@ export const RECOMMENDATION_STEP_LABELS: Record = { traceSource: 'Traces', days: 'Lookback', sessions: 'Sessions', + 'kms-key-arn': 'KMS Key', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts index 94c3c66d1..99a22bcda 100644 --- a/src/cli/tui/screens/recommendation/useRecommendationWizard.ts +++ b/src/cli/tui/screens/recommendation/useRecommendationWizard.ts @@ -1,8 +1,8 @@ import type { - RecommendationInputSourceKind, + RecommendationInputSource as RecommendationInputSourceKind, RecommendationType, - TraceSourceKind, -} from '../../../operations/recommendation'; + RecommendationTraceSource as TraceSourceKind, +} from '../../../operations/jobs'; import type { RecommendationStep, RecommendationWizardConfig } from './types'; import { DEFAULT_LOOKBACK_DAYS } from './types'; import { useCallback, useState } from 'react'; @@ -50,6 +50,7 @@ function getAllSteps( steps.push('days'); } + steps.push('kms-key-arn'); steps.push('confirm'); return steps; } @@ -70,6 +71,7 @@ function getDefaultConfig(): RecommendationWizardConfig { bundleFields: [], systemPromptJsonPath: '', toolDescJsonPaths: [], + kmsKeyArn: '', }; } @@ -205,6 +207,14 @@ export function useRecommendationWizard() { [advance] ); + const setKmsKeyArn = useCallback( + (kmsKeyArn: string) => { + setConfig(c => ({ ...c, kmsKeyArn })); + advance('kms-key-arn'); + }, + [advance] + ); + const reset = useCallback(() => { setConfig(getDefaultConfig()); setStep('type'); @@ -227,6 +237,7 @@ export function useRecommendationWizard() { setTraceSource, setDays, setSessions, + setKmsKeyArn, reset, }; } diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index dcf77ca1b..af7c4633e 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -1,8 +1,8 @@ import type { RemovableGatewayTarget, RemovalPreview } from '../../../operations/remove'; -import { paymentManagerPrimitive } from '../../../primitives/registry'; +import type { OrphanAction } from '../../../primitives/HarnessPrimitive'; +import { harnessPrimitive, paymentManagerPrimitive } from '../../../primitives/registry'; import { ErrorPrompt, Panel, Screen, SelectScreen } from '../../components'; import { - useRemovableABTests, useRemovableAgents, useRemovableConfigBundles, useRemovableDatasets, @@ -11,6 +11,7 @@ import { useRemovableGateways, useRemovableHarnesses, useRemovableIdentities, + useRemovableKnowledgeBases, useRemovableMemories, useRemovableOnlineEvalConfigs, useRemovablePaymentManagers, @@ -18,7 +19,6 @@ import { useRemovablePolicyEngines, useRemovableRuntimeEndpoints, useRemovalPreview, - useRemoveABTest, useRemoveAgent, useRemoveConfigBundle, useRemoveDataset, @@ -27,13 +27,13 @@ import { useRemoveGatewayTarget, useRemoveHarness, useRemoveIdentity, + useRemoveKnowledgeBase, useRemoveMemory, useRemoveOnlineEvalConfig, useRemovePolicy, useRemovePolicyEngine, useRemoveRuntimeEndpoint, } from '../../hooks/useRemove'; -import { RemoveABTestScreen } from '../ab-test/RemoveABTestScreen'; import { RemoveAgentScreen } from './RemoveAgentScreen'; import { RemoveAllScreen } from './RemoveAllScreen'; import { RemoveConfigBundleScreen } from './RemoveConfigBundleScreen'; @@ -43,6 +43,7 @@ import { RemoveEvaluatorScreen } from './RemoveEvaluatorScreen'; import { RemoveGatewayScreen } from './RemoveGatewayScreen'; import { RemoveGatewayTargetScreen } from './RemoveGatewayTargetScreen'; import { RemoveIdentityScreen } from './RemoveIdentityScreen'; +import { RemoveKnowledgeBaseScreen } from './RemoveKnowledgeBaseScreen'; import { RemoveMemoryScreen } from './RemoveMemoryScreen'; import { RemoveOnlineEvalScreen } from './RemoveOnlineEvalScreen'; import { RemovePolicyEngineScreen } from './RemovePolicyEngineScreen'; @@ -64,13 +65,14 @@ type FlowState = | { name: 'select-identity' } | { name: 'select-evaluator' } | { name: 'select-dataset' } + | { name: 'select-knowledge-base' } | { name: 'select-online-eval' } | { name: 'select-policy-engine' } | { name: 'select-policy' } | { name: 'select-harness' } | { name: 'confirm-harness'; harnessName: string; preview: RemovalPreview } + | { name: 'confirm-orphan-harness'; harnessName: string } | { name: 'select-config-bundle' } - | { name: 'select-ab-test' } | { name: 'select-runtime-endpoint' } | { name: 'select-payment' } | { name: 'confirm-agent'; agentName: string; preview: RemovalPreview } @@ -80,11 +82,11 @@ type FlowState = | { name: 'confirm-identity'; identityName: string; preview: RemovalPreview } | { name: 'confirm-evaluator'; evaluatorName: string; preview: RemovalPreview } | { name: 'confirm-dataset'; datasetName: string; preview: RemovalPreview } + | { name: 'confirm-knowledge-base'; knowledgeBaseName: string; preview: RemovalPreview } | { name: 'confirm-online-eval'; configName: string; preview: RemovalPreview } | { name: 'confirm-policy-engine'; engineName: string; preview: RemovalPreview } | { name: 'confirm-policy'; compositeKey: string; policyName: string; preview: RemovalPreview } | { name: 'confirm-config-bundle'; bundleName: string; preview: RemovalPreview } - | { name: 'confirm-ab-test'; testName: string; preview: RemovalPreview } | { name: 'confirm-runtime-endpoint'; endpointName: string; preview: RemovalPreview } | { name: 'confirm-payment'; managerName: string; preview: RemovalPreview } | { name: 'loading'; message: string } @@ -96,11 +98,11 @@ type FlowState = | { name: 'identity-success'; identityName: string; logFilePath?: string } | { name: 'evaluator-success'; evaluatorName: string; logFilePath?: string } | { name: 'dataset-success'; datasetName: string; logFilePath?: string } + | { name: 'knowledge-base-success'; knowledgeBaseName: string; logFilePath?: string } | { name: 'online-eval-success'; configName: string; logFilePath?: string } | { name: 'policy-engine-success'; engineName: string; logFilePath?: string } | { name: 'policy-success'; policyName: string; logFilePath?: string } | { name: 'config-bundle-success'; bundleName: string; logFilePath?: string } - | { name: 'ab-test-success'; testName: string; logFilePath?: string } | { name: 'runtime-endpoint-success'; endpointName: string; logFilePath?: string } | { name: 'payment-success'; managerName: string } | { name: 'remove-all' } @@ -125,11 +127,12 @@ interface RemoveFlowProps { | 'credential' | 'evaluator' | 'online-eval' + | 'online-insights' | 'policy-engine' | 'policy' | 'config-bundle' - | 'ab-test' | 'dataset' + | 'knowledge-base' | 'payment' | 'payment-manager' | 'payment-connector' @@ -165,16 +168,18 @@ export function RemoveFlow({ return { name: 'select-evaluator' }; case 'dataset': return { name: 'select-dataset' }; + case 'knowledge-base': + return { name: 'select-knowledge-base' }; case 'online-eval': return { name: 'select-online-eval' }; + case 'online-insights': + return { name: 'select-online-eval' }; case 'policy-engine': return { name: 'select-policy-engine' }; case 'policy': return { name: 'select-policy' }; case 'config-bundle': return { name: 'select-config-bundle' }; - case 'ab-test': - return { name: 'select-ab-test' }; case 'runtime-endpoint': return { name: 'select-runtime-endpoint' }; case 'payment': @@ -198,6 +203,11 @@ export function RemoveFlow({ const { identities, isLoading: isLoadingIdentities, refresh: refreshIdentities } = useRemovableIdentities(); const { evaluators, isLoading: isLoadingEvaluators, refresh: refreshEvaluators } = useRemovableEvaluators(); const { datasets, isLoading: isLoadingDatasets, refresh: refreshDatasets } = useRemovableDatasets(); + const { + knowledgeBases, + isLoading: isLoadingKnowledgeBases, + refresh: refreshKnowledgeBases, + } = useRemovableKnowledgeBases(); const { onlineEvalConfigs, isLoading: isLoadingOnlineEvals, @@ -214,7 +224,6 @@ export function RemoveFlow({ isLoading: isLoadingConfigBundles, refresh: refreshConfigBundles, } = useRemovableConfigBundles(); - const { abTests } = useRemovableABTests(); const { endpoints: runtimeEndpoints, isLoading: isLoadingRuntimeEndpoints, @@ -232,6 +241,7 @@ export function RemoveFlow({ isLoadingIdentities || isLoadingEvaluators || isLoadingDatasets || + isLoadingKnowledgeBases || isLoadingOnlineEvals || isLoadingPolicyEngines || isLoadingPolicies || @@ -249,11 +259,11 @@ export function RemoveFlow({ loadIdentityPreview, loadEvaluatorPreview, loadDatasetPreview, + loadKnowledgeBasePreview, loadOnlineEvalPreview, loadPolicyEnginePreview, loadPolicyPreview, loadConfigBundlePreview, - loadABTestPreview, loadRuntimeEndpointPreview, reset: resetPreview, } = useRemovalPreview(); @@ -267,11 +277,11 @@ export function RemoveFlow({ const { remove: removeIdentityOp, reset: resetRemoveIdentity } = useRemoveIdentity(); const { remove: removeEvaluatorOp, reset: resetRemoveEvaluator } = useRemoveEvaluator(); const { remove: removeDatasetOp, reset: resetRemoveDataset } = useRemoveDataset(); + const { remove: removeKnowledgeBaseOp, reset: resetRemoveKnowledgeBase } = useRemoveKnowledgeBase(); const { remove: removeOnlineEvalOp, reset: resetRemoveOnlineEval } = useRemoveOnlineEvalConfig(); const { remove: removePolicyEngineOp, reset: resetRemovePolicyEngine } = useRemovePolicyEngine(); const { remove: removePolicyOp, reset: resetRemovePolicy } = useRemovePolicy(); const { remove: removeConfigBundleOp, reset: resetRemoveConfigBundle } = useRemoveConfigBundle(); - const { remove: removeABTestOp, reset: resetRemoveABTest } = useRemoveABTest(); const { remove: removeRuntimeEndpointOp, reset: resetRemoveRuntimeEndpoint } = useRemoveRuntimeEndpoint(); // Track pending result state @@ -302,11 +312,11 @@ export function RemoveFlow({ 'identity-success', 'evaluator-success', 'dataset-success', + 'knowledge-base-success', 'online-eval-success', 'policy-engine-success', 'policy-success', 'config-bundle-success', - 'ab-test-success', 'runtime-endpoint-success', 'payment-success', ]; @@ -345,6 +355,9 @@ export function RemoveFlow({ case 'dataset': setFlow({ name: 'select-dataset' }); break; + case 'knowledge-base': + setFlow({ name: 'select-knowledge-base' }); + break; case 'online-eval': setFlow({ name: 'select-online-eval' }); break; @@ -357,9 +370,6 @@ export function RemoveFlow({ case 'config-bundle': setFlow({ name: 'select-config-bundle' }); break; - case 'ab-test': - setFlow({ name: 'select-ab-test' }); - break; case 'runtime-endpoint': setFlow({ name: 'select-runtime-endpoint' }); break; @@ -399,6 +409,13 @@ export function RemoveFlow({ const handleSelectHarness = useCallback( async (harnessName: string) => { + // Imperative-build orphans need an explicit delete-and-keep / delete-and-discard choice + // (deleting a real AWS resource), so they bypass the plain confirm and the force path — + // we never auto-delete an orphan, even with --yes. + if (harnessPrimitive && (await harnessPrimitive.isOrphan(harnessName))) { + setFlow({ name: 'confirm-orphan-harness', harnessName }); + return; + } const result = await loadHarnessPreview(harnessName); if (result.ok) { if (force) { @@ -419,6 +436,16 @@ export function RemoveFlow({ [loadHarnessPreview, force, removeHarnessOp] ); + const handleConfirmOrphanHarness = useCallback(async (harnessName: string, orphanAction: OrphanAction) => { + setFlow({ name: 'loading', message: `Deleting orphan harness ${harnessName} from AWS...` }); + const result = await harnessPrimitive!.remove(harnessName, { orphanAction }); + if (result.success) { + setFlow({ name: 'harness-success', harnessName }); + } else { + setFlow({ name: 'error', message: result.error.message }); + } + }, []); + const handleSelectGateway = useCallback( async (gatewayName: string) => { const result = await loadGatewayPreview(gatewayName); @@ -551,6 +578,28 @@ export function RemoveFlow({ [loadDatasetPreview, force, removeDatasetOp] ); + const handleSelectKnowledgeBase = useCallback( + async (knowledgeBaseName: string) => { + const result = await loadKnowledgeBasePreview(knowledgeBaseName); + if (result.ok) { + if (force) { + setFlow({ name: 'loading', message: `Removing knowledge base ${knowledgeBaseName}...` }); + const removeResult = await removeKnowledgeBaseOp(knowledgeBaseName, result.preview); + if (removeResult.success) { + setFlow({ name: 'knowledge-base-success', knowledgeBaseName }); + } else { + setFlow({ name: 'error', message: removeResult.error.message }); + } + } else { + setFlow({ name: 'confirm-knowledge-base', knowledgeBaseName, preview: result.preview }); + } + } else { + setFlow({ name: 'error', message: result.error }); + } + }, + [loadKnowledgeBasePreview, force, removeKnowledgeBaseOp] + ); + const handleSelectOnlineEval = useCallback( async (configName: string) => { const result = await loadOnlineEvalPreview(configName); @@ -642,28 +691,6 @@ export function RemoveFlow({ [loadConfigBundlePreview, force, removeConfigBundleOp] ); - const handleSelectABTest = useCallback( - async (testName: string) => { - const result = await loadABTestPreview(testName); - if (result.ok) { - if (force) { - setFlow({ name: 'loading', message: `Removing AB test ${testName}...` }); - const removeResult = await removeABTestOp(testName, result.preview); - if (removeResult.success) { - setFlow({ name: 'ab-test-success', testName }); - } else { - setFlow({ name: 'error', message: removeResult.error.message }); - } - } else { - setFlow({ name: 'confirm-ab-test', testName, preview: result.preview }); - } - } else { - setFlow({ name: 'error', message: result.error }); - } - }, - [loadABTestPreview, force, removeABTestOp] - ); - const handleSelectRuntimeEndpoint = useCallback( async (endpointName: string) => { const result = await loadRuntimeEndpointPreview(endpointName); @@ -738,6 +765,9 @@ export function RemoveFlow({ case 'online-eval': void handleSelectOnlineEval(initialResourceName); break; + case 'online-insights': + void handleSelectOnlineEval(initialResourceName); + break; case 'policy-engine': void handleSelectPolicyEngine(initialResourceName); break; @@ -747,15 +777,15 @@ export function RemoveFlow({ case 'config-bundle': void handleSelectConfigBundle(initialResourceName); break; - case 'ab-test': - void handleSelectABTest(initialResourceName); - break; case 'runtime-endpoint': void handleSelectRuntimeEndpoint(initialResourceName); break; case 'dataset': void handleSelectDataset(initialResourceName); break; + case 'knowledge-base': + void handleSelectKnowledgeBase(initialResourceName); + break; case 'payment': case 'payment-manager': void handleSelectPaymentManager(initialResourceName); @@ -772,11 +802,11 @@ export function RemoveFlow({ handleSelectIdentity, handleSelectEvaluator, handleSelectDataset, + handleSelectKnowledgeBase, handleSelectOnlineEval, handleSelectPolicyEngine, handleSelectPolicy, handleSelectConfigBundle, - handleSelectABTest, handleSelectRuntimeEndpoint, handleSelectPaymentManager, ]); @@ -910,6 +940,26 @@ export function RemoveFlow({ [removeDatasetOp] ); + const handleConfirmKnowledgeBase = useCallback( + async (knowledgeBaseName: string, preview: RemovalPreview) => { + pendingResultRef.current = null; + setResultReady(false); + setFlow({ name: 'loading', message: `Removing knowledge base ${knowledgeBaseName}...` }); + const result = await removeKnowledgeBaseOp(knowledgeBaseName, preview); + if (result.success) { + pendingResultRef.current = { + name: 'knowledge-base-success', + knowledgeBaseName, + logFilePath: result.logFilePath, + }; + } else { + pendingResultRef.current = { name: 'error', message: result.error.message }; + } + setResultReady(true); + }, + [removeKnowledgeBaseOp] + ); + const handleConfirmOnlineEval = useCallback( async (configName: string, preview: RemovalPreview) => { pendingResultRef.current = null; @@ -974,22 +1024,6 @@ export function RemoveFlow({ [removeConfigBundleOp] ); - const handleConfirmABTest = useCallback( - async (testName: string, preview: RemovalPreview) => { - pendingResultRef.current = null; - setResultReady(false); - setFlow({ name: 'loading', message: `Removing AB test ${testName}...` }); - const result = await removeABTestOp(testName, preview); - if (result.success) { - pendingResultRef.current = { name: 'ab-test-success', testName, logFilePath: result.logFilePath }; - } else { - pendingResultRef.current = { name: 'error', message: result.error.message }; - } - setResultReady(true); - }, - [removeABTestOp] - ); - const handleConfirmRuntimeEndpoint = useCallback( async (endpointName: string, preview: RemovalPreview) => { pendingResultRef.current = null; @@ -1016,11 +1050,11 @@ export function RemoveFlow({ resetRemoveIdentity(); resetRemoveEvaluator(); resetRemoveDataset(); + resetRemoveKnowledgeBase(); resetRemoveOnlineEval(); resetRemovePolicyEngine(); resetRemovePolicy(); resetRemoveConfigBundle(); - resetRemoveABTest(); resetRemoveRuntimeEndpoint(); }, [ resetPreview, @@ -1032,11 +1066,11 @@ export function RemoveFlow({ resetRemoveIdentity, resetRemoveEvaluator, resetRemoveDataset, + resetRemoveKnowledgeBase, resetRemoveOnlineEval, resetRemovePolicyEngine, resetRemovePolicy, resetRemoveConfigBundle, - resetRemoveABTest, resetRemoveRuntimeEndpoint, ]); @@ -1050,6 +1084,7 @@ export function RemoveFlow({ refreshIdentities(), refreshEvaluators(), refreshDatasets(), + refreshKnowledgeBases(), refreshOnlineEvals(), refreshPolicyEngines(), refreshPolicies(), @@ -1066,6 +1101,7 @@ export function RemoveFlow({ refreshIdentities, refreshEvaluators, refreshDatasets, + refreshKnowledgeBases, refreshOnlineEvals, refreshPolicyEngines, refreshPolicies, @@ -1094,9 +1130,9 @@ export function RemoveFlow({ policyEngineCount={policyEngines.length} policyCount={policies.length} configBundleCount={configBundles.length} - abTestCount={abTests.length} runtimeEndpointCount={runtimeEndpoints.length} datasetCount={datasets.length} + knowledgeBaseCount={knowledgeBases.length} paymentCount={paymentManagers.length} /> ); @@ -1219,6 +1255,19 @@ export function RemoveFlow({ ); } + if (flow.name === 'select-knowledge-base') { + if (initialResourceName && isLoading) { + return null; + } + return ( + void handleSelectKnowledgeBase(name)} + onExit={() => setFlow({ name: 'select' })} + /> + ); + } + if (flow.name === 'select-online-eval') { if (initialResourceName && isLoading) { return null; @@ -1271,19 +1320,6 @@ export function RemoveFlow({ ); } - if (flow.name === 'select-ab-test') { - if (initialResourceName && isLoading) { - return null; - } - return ( - void handleSelectABTest(name)} - onExit={() => setFlow({ name: 'select' })} - /> - ); - } - if (flow.name === 'select-runtime-endpoint') { if (initialResourceName && isLoading) { return null; @@ -1342,6 +1378,42 @@ export function RemoveFlow({ ); } + if (flow.name === 'confirm-orphan-harness') { + const orphanName = flow.harnessName; + return ( + + title={`Remove Harness: ${orphanName}`} + color="yellow" + headerContent={ + + {`"${orphanName}" was created by the preview build and is not managed by CloudFormation, so CloudFormation cannot delete it. This will delete it directly from your AWS account.`} + + } + items={[ + { + id: 'keep', + title: 'Delete it and keep it in agentcore.json', + description: 'Moves to GA — the next `agentcore deploy` recreates it under CloudFormation.', + }, + { + id: 'discard', + title: 'Delete it and remove it from agentcore.json', + description: 'You no longer want this harness.', + }, + { id: 'cancel', title: 'Cancel', description: 'Leave the harness untouched.', spaceBefore: true }, + ]} + onSelect={item => { + if (item.id === 'cancel') { + setFlow({ name: 'select-harness' }); + } else { + void handleConfirmOrphanHarness(orphanName, item.id as OrphanAction); + } + }} + onExit={() => setFlow({ name: 'select-harness' })} + /> + ); + } + if (flow.name === 'confirm-gateway') { return ( void handleConfirmKnowledgeBase(flow.knowledgeBaseName, flow.preview)} + onCancel={() => setFlow({ name: 'select-knowledge-base' })} + /> + ); + } + if (flow.name === 'confirm-online-eval') { return ( void handleConfirmABTest(flow.testName, flow.preview)} - onCancel={() => setFlow({ name: 'select-ab-test' })} - /> - ); - } - if (flow.name === 'confirm-runtime-endpoint') { return ( { resetAll(); @@ -1640,12 +1712,12 @@ export function RemoveFlow({ ); } - if (flow.name === 'policy-engine-success') { + if (flow.name === 'online-eval-success') { return ( { resetAll(); @@ -1656,12 +1728,12 @@ export function RemoveFlow({ ); } - if (flow.name === 'policy-success') { + if (flow.name === 'policy-engine-success') { return ( { resetAll(); @@ -1672,12 +1744,12 @@ export function RemoveFlow({ ); } - if (flow.name === 'config-bundle-success') { + if (flow.name === 'policy-success') { return ( { resetAll(); @@ -1688,12 +1760,12 @@ export function RemoveFlow({ ); } - if (flow.name === 'ab-test-success') { + if (flow.name === 'config-bundle-success') { return ( { resetAll(); diff --git a/src/cli/tui/screens/remove/RemoveKnowledgeBaseScreen.tsx b/src/cli/tui/screens/remove/RemoveKnowledgeBaseScreen.tsx new file mode 100644 index 000000000..b6ac035a8 --- /dev/null +++ b/src/cli/tui/screens/remove/RemoveKnowledgeBaseScreen.tsx @@ -0,0 +1,26 @@ +import type { RemovableKnowledgeBase } from '../../../primitives/KnowledgeBasePrimitive'; +import { SelectScreen } from '../../components'; +import React from 'react'; + +interface RemoveKnowledgeBaseScreenProps { + knowledgeBases: RemovableKnowledgeBase[]; + onSelect: (knowledgeBaseName: string) => void; + onExit: () => void; +} + +export function RemoveKnowledgeBaseScreen({ knowledgeBases, onSelect, onExit }: RemoveKnowledgeBaseScreenProps) { + const items = knowledgeBases.map(kb => ({ + id: kb.name, + title: kb.name, + description: 'Knowledge Base', + })); + + return ( + onSelect(item.id)} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index 2f54c6010..16acf4a2d 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -1,4 +1,4 @@ -import { isPreviewEnabled } from '../../../feature-flags'; +import { isGatedFeaturesEnabled, isPreviewEnabled } from '../../../feature-flags'; import type { SelectableItem } from '../../components'; import { SelectScreen } from '../../components'; import { useMemo } from 'react'; @@ -10,12 +10,13 @@ export type RemoveResourceType = | 'credential' | 'evaluator' | 'online-eval' + | 'online-insights' | 'policy-engine' | 'policy' | 'gateway' | 'gateway-target' + | 'knowledge-base' | 'config-bundle' - | 'ab-test' | 'runtime-endpoint' | 'dataset' | 'payment' @@ -32,11 +33,15 @@ const REMOVE_RESOURCES: { id: RemoveResourceType; title: string; description: st { id: 'online-eval', title: 'Online Eval Config', description: 'Remove an online eval config' }, { id: 'policy-engine', title: 'Policy Engine', description: 'Remove a policy engine' }, { id: 'policy', title: 'Policy', description: 'Remove a policy from a policy engine' }, - { id: 'payment', title: 'Payment', description: 'Remove a payment manager' }, + { id: 'payment', title: 'Payment [preview]', description: 'Remove a payment manager' }, { id: 'gateway', title: 'Gateway', description: 'Remove a gateway' }, { id: 'gateway-target', title: 'Gateway Target', description: 'Remove a gateway target' }, - { id: 'config-bundle', title: 'Configuration Bundle [preview]', description: 'Remove a configuration bundle' }, - { id: 'ab-test', title: 'AB Test [preview]', description: 'Remove an A/B test' }, + { + id: 'knowledge-base', + title: 'Knowledge Base', + description: 'Remove a knowledge base (cascade-prunes connector gateway targets)', + }, + { id: 'config-bundle', title: 'Configuration Bundle', description: 'Remove a configuration bundle' }, { id: 'runtime-endpoint', title: 'Runtime Endpoint', description: 'Remove a runtime endpoint' }, { id: 'dataset', title: 'Dataset', description: 'Remove a dataset' }, { id: 'all', title: 'All', description: 'Reset entire agentcore project' }, @@ -67,12 +72,12 @@ interface RemoveScreenProps { policyCount: number; /** Number of configuration bundles available for removal */ configBundleCount: number; - /** Number of AB tests available for removal */ - abTestCount: number; /** Number of runtime endpoints available for removal */ runtimeEndpointCount: number; /** Number of datasets available for removal */ datasetCount: number; + /** Number of knowledge bases available for removal */ + knowledgeBaseCount: number; /** Number of payment managers available for removal */ paymentCount: number; } @@ -91,9 +96,9 @@ export function RemoveScreen({ policyEngineCount, policyCount, configBundleCount, - abTestCount, runtimeEndpointCount, datasetCount, + knowledgeBaseCount, paymentCount, }: RemoveScreenProps) { const items: SelectableItem[] = useMemo(() => { @@ -168,12 +173,6 @@ export function RemoveScreen({ description = 'No configuration bundles to remove'; } break; - case 'ab-test': - if (abTestCount === 0) { - disabled = true; - description = 'No AB tests to remove'; - } - break; case 'runtime-endpoint': if (runtimeEndpointCount === 0) { disabled = true; @@ -186,6 +185,15 @@ export function RemoveScreen({ description = 'No datasets to remove'; } break; + case 'knowledge-base': + if (!isGatedFeaturesEnabled()) { + disabled = true; + description = 'Coming soon'; + } else if (knowledgeBaseCount === 0) { + disabled = true; + description = 'No knowledge bases to remove'; + } + break; case 'payment': if (paymentCount === 0) { disabled = true; @@ -211,9 +219,9 @@ export function RemoveScreen({ policyEngineCount, policyCount, configBundleCount, - abTestCount, runtimeEndpointCount, datasetCount, + knowledgeBaseCount, paymentCount, ]); diff --git a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx index 345062af3..f1227bccd 100644 --- a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -1,9 +1,17 @@ import { RemoveScreen } from '../RemoveScreen.js'; import { render } from 'ink-testing-library'; import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('RemoveScreen', () => { + const originalGate = process.env.ENABLE_GATED_FEATURES; + beforeEach(() => { + process.env.ENABLE_GATED_FEATURES = '1'; + }); + afterEach(() => { + if (originalGate === undefined) delete process.env.ENABLE_GATED_FEATURES; + else process.env.ENABLE_GATED_FEATURES = originalGate; + }); it('gateway and gateway-target options enabled when counts > 0', () => { const onSelect = vi.fn(); const onExit = vi.fn(); @@ -23,9 +31,9 @@ describe('RemoveScreen', () => { policyEngineCount={1} policyCount={1} configBundleCount={1} - abTestCount={0} runtimeEndpointCount={1} datasetCount={0} + knowledgeBaseCount={0} paymentCount={1} /> ); @@ -59,9 +67,9 @@ describe('RemoveScreen', () => { policyEngineCount={0} policyCount={0} configBundleCount={0} - abTestCount={0} runtimeEndpointCount={0} datasetCount={0} + knowledgeBaseCount={0} paymentCount={0} /> ); @@ -72,7 +80,37 @@ describe('RemoveScreen', () => { expect(lastFrame()).toContain('No policies to remove'); }); - it('AB test option enabled when abTestCount > 0', () => { + it('Knowledge Base option enabled when knowledgeBaseCount > 0', () => { + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('Knowledge Base'); + expect(lastFrame()).not.toContain('No knowledge bases to remove'); + }); + + it('Knowledge Base option disabled when knowledgeBaseCount = 0', () => { const onSelect = vi.fn(); const onExit = vi.fn(); @@ -91,18 +129,18 @@ describe('RemoveScreen', () => { policyEngineCount={0} policyCount={0} configBundleCount={0} - abTestCount={2} runtimeEndpointCount={0} datasetCount={0} + knowledgeBaseCount={0} paymentCount={0} /> ); - expect(lastFrame()).toContain('AB Test'); - expect(lastFrame()).not.toContain('No AB tests to remove'); + expect(lastFrame()).toContain('No knowledge bases to remove'); }); - it('AB test option disabled when abTestCount = 0', () => { + it('Knowledge Base option shows Coming soon when ENABLE_GATED_FEATURES is unset', () => { + delete process.env.ENABLE_GATED_FEATURES; const onSelect = vi.fn(); const onExit = vi.fn(); @@ -121,13 +159,15 @@ describe('RemoveScreen', () => { policyEngineCount={0} policyCount={0} configBundleCount={0} - abTestCount={0} runtimeEndpointCount={0} datasetCount={0} + knowledgeBaseCount={3} paymentCount={0} /> ); - expect(lastFrame()).toContain('No AB tests to remove'); + expect(lastFrame()).toContain('Knowledge Base'); + expect(lastFrame()).toContain('Coming soon'); + expect(lastFrame()).not.toContain('No knowledge bases to remove'); }); }); diff --git a/src/cli/tui/screens/remove/index.ts b/src/cli/tui/screens/remove/index.ts index 8d77b9b10..7a2105002 100644 --- a/src/cli/tui/screens/remove/index.ts +++ b/src/cli/tui/screens/remove/index.ts @@ -6,6 +6,7 @@ export { RemoveFlow } from './RemoveFlow'; export { RemoveGatewayScreen } from './RemoveGatewayScreen'; export { RemoveIdentityScreen } from './RemoveIdentityScreen'; export { RemoveGatewayTargetScreen } from './RemoveGatewayTargetScreen'; +export { RemoveKnowledgeBaseScreen } from './RemoveKnowledgeBaseScreen'; export { RemoveMemoryScreen } from './RemoveMemoryScreen'; export { RemoveOnlineEvalScreen } from './RemoveOnlineEvalScreen'; export { RemovePolicyEngineScreen } from './RemovePolicyEngineScreen'; diff --git a/src/cli/tui/screens/run-ab-test/ABTestJobsHistoryScreen.tsx b/src/cli/tui/screens/run-ab-test/ABTestJobsHistoryScreen.tsx new file mode 100644 index 000000000..9b789d1cf --- /dev/null +++ b/src/cli/tui/screens/run-ab-test/ABTestJobsHistoryScreen.tsx @@ -0,0 +1,196 @@ +import { ConfigIO } from '../../../../lib'; +import { validateAwsCredentials } from '../../../aws/account'; +import { getErrorMessage } from '../../../errors'; +import { createJobEngine } from '../../../operations/jobs'; +import type { ABTestJobRecord } from '../../../operations/jobs'; +import { ErrorPrompt, Panel, Screen } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { ABTestDetailView, lifecycleColor, statusColor } from '../job-detail'; +import { Box, Text, useStdout } from 'ink'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function formatShortDate(timestamp: string): string { + const d = new Date(timestamp); + const mon = MONTHS[d.getMonth()]; + const day = d.getDate(); + const h = d.getHours(); + const m = d.getMinutes().toString().padStart(2, '0'); + const ampm = h >= 12 ? 'PM' : 'AM'; + const h12 = h % 12 || 12; + return `${mon} ${day} ${h12}:${m} ${ampm}`; +} + +const CHROME_LINES = 9; + +// ───────────────────────────────────────────────────────────────────────────── +// List view +// ───────────────────────────────────────────────────────────────────────────── + +function ABTestListView({ + records, + onSelect, + onExit, + availableHeight, +}: { + records: ABTestJobRecord[]; + onSelect: (record: ABTestJobRecord) => void; + onExit: () => void; + availableHeight: number; +}) { + const nav = useListNavigation({ + items: records, + onSelect: item => onSelect(item), + onExit, + isActive: true, + }); + + const maxVisible = Math.max(1, availableHeight - 3); + const visible = useMemo(() => { + let start = 0; + if (nav.selectedIndex >= maxVisible) { + start = nav.selectedIndex - maxVisible + 1; + } + return { items: records.slice(start, start + maxVisible), startIdx: start }; + }, [records, nav.selectedIndex, maxVisible]); + + return ( + + + A/B Test Jobs + + {records.length} A/B test{records.length !== 1 ? 's' : ''} + + + {visible.items.map((rec, vIdx) => { + const idx = visible.startIdx + vIdx; + const selected = idx === nav.selectedIndex; + const date = rec.createdAt ? formatShortDate(rec.createdAt) : 'unknown'; + return ( + + {selected ? '❯' : ' '} + {date.padEnd(16)} + {rec.status.padEnd(10)} + {rec.lifecycleStatus.padEnd(10)} + {rec.name} + + ); + })} + {visible.startIdx + maxVisible < records.length && ( + ↓ {records.length - visible.startIdx - maxVisible} more + )} + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main screen +// ───────────────────────────────────────────────────────────────────────────── + +type FlowState = + | { name: 'loading' } + | { name: 'creds-error'; message: string } + | { name: 'error'; message: string } + | { name: 'loaded'; records: ABTestJobRecord[] }; + +interface ABTestJobsHistoryScreenProps { + onExit: () => void; +} + +export function ABTestJobsHistoryScreen({ onExit }: ABTestJobsHistoryScreenProps) { + const engine = useMemo(() => createJobEngine(new ConfigIO()), []); + const { stdout } = useStdout(); + const terminalHeight = stdout?.rows ?? 24; + const availableHeight = Math.max(6, terminalHeight - CHROME_LINES); + + const [flow, setFlow] = useState({ name: 'loading' }); + const [selectedRecord, setSelectedRecord] = useState(null); + + useEffect(() => { + let cancelled = false; + + void (async () => { + try { + await validateAwsCredentials(); + } catch (err) { + if (!cancelled) setFlow({ name: 'creds-error', message: getErrorMessage(err) }); + return; + } + + try { + const records = await engine.list({ type: 'ab-test' }); + if (!cancelled) setFlow({ name: 'loaded', records }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [engine]); + + const handleUpdate = useCallback((updated: ABTestJobRecord) => { + setSelectedRecord(updated); + setFlow(prev => + prev.name === 'loaded' ? { ...prev, records: prev.records.map(r => (r.id === updated.id ? updated : r)) } : prev + ); + }, []); + + if (flow.name === 'loading') { + return ( + + Loading A/B test jobs... + + ); + } + + if (flow.name === 'creds-error') { + return ; + } + + if (flow.name === 'error') { + return ( + + {flow.message} + + ); + } + + if (flow.records.length === 0) { + return ( + + + No A/B test jobs found. + Run an A/B test from the TUI or CLI to see results here. + + + ); + } + + const helpText = selectedRecord ? 'Esc/B back to list' : HELP_TEXT.NAVIGATE_SELECT; + + return ( + + {selectedRecord ? ( + setSelectedRecord(null)} + onUpdate={handleUpdate} + /> + ) : ( + + )} + + ); +} diff --git a/src/cli/tui/screens/run-ab-test/RunABTestFlow.tsx b/src/cli/tui/screens/run-ab-test/RunABTestFlow.tsx new file mode 100644 index 000000000..5347f3de2 --- /dev/null +++ b/src/cli/tui/screens/run-ab-test/RunABTestFlow.tsx @@ -0,0 +1,805 @@ +import { ConfigIO } from '../../../../lib'; +import { validateAwsCredentials } from '../../../aws/account'; +import { getErrorMessage } from '../../../errors'; +import { createJobEngine } from '../../../operations/jobs'; +import type { ABTestJobRecord, ABTestMode, StartABTestJobOptions } from '../../../operations/jobs'; +import { + ConfirmReview, + ErrorPrompt, + GradientText, + Panel, + Screen, + StepIndicator, + TextInput, + WizardSelect, +} from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import type { ABTestResources, RunABTestConfig, RunABTestStep } from './types'; +import { Box, Text } from 'ink'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +// ============================================================================ +// Resource loading +// ============================================================================ + +const CONFIG_BUNDLE_STEPS: RunABTestStep[] = [ + 'mode', + 'gateway', + 'control', + 'treatment', + 'onlineEval', + 'filter', + 'name', + 'confirm', +]; + +const TARGET_BASED_STEPS: RunABTestStep[] = ['mode', 'gateway', 'control', 'treatment', 'filter', 'name', 'confirm']; + +const STEP_LABELS: Record = { + mode: 'Mode', + gateway: 'Gateway', + control: 'Control', + treatment: 'Treatment', + onlineEval: 'Online Eval', + filter: 'Filter', + name: 'Name', + confirm: 'Confirm', +}; + +async function loadResources(): Promise<{ resources: ABTestResources; region: string }> { + const configIO = new ConfigIO(); + const [projectSpec, deployedState, awsTargets] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readDeployedState(), + configIO.resolveAWSDeploymentTargets(), + ]); + + const bundles: { name: string; bundleId: string }[] = []; + const gateways = new Set(); + const targets = new Set(); + const onlineEvalConfigs = new Set(); + + for (const target of Object.values(deployedState.targets ?? {})) { + const resources = target.resources; + if (!resources) continue; + for (const [name, state] of Object.entries(resources.configBundles ?? {})) { + bundles.push({ name, bundleId: state.bundleId }); + } + for (const name of Object.keys(resources.mcp?.gateways ?? {})) gateways.add(name); + for (const name of Object.keys(resources.gateways ?? {})) gateways.add(name); + for (const name of Object.keys(resources.onlineEvalConfigs ?? {})) onlineEvalConfigs.add(name); + } + + // Gateway-target names come from project spec (deployed as `${project}-${target}`). + for (const gw of projectSpec.agentCoreGateways ?? []) { + for (const t of gw.targets ?? []) { + if (t.targetType === 'httpRuntime') targets.add(t.name); + } + } + + const runtimes = (projectSpec.runtimes ?? []).map(r => r.name); + const region = awsTargets[0]?.region ?? process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; + + return { + resources: { + gateways: [...gateways], + bundles, + targets: [...targets], + runtimes, + onlineEvalConfigs: [...onlineEvalConfigs], + }, + region, + }; +} + +// ============================================================================ +// Flow Component +// ============================================================================ + +type FlowState = + | { name: 'loading' } + | { name: 'wizard'; resources: ABTestResources; region: string } + | { name: 'starting'; config: RunABTestConfig } + | { name: 'started'; record: ABTestJobRecord; config: RunABTestConfig } + | { name: 'creds-error'; message: string } + | { name: 'error'; message: string }; + +interface RunABTestFlowProps { + onExit: () => void; + /** Navigate to the A/B Test Jobs screen (falls back to onExit when not provided). */ + onViewJobs?: () => void; +} + +export function RunABTestFlow({ onExit, onViewJobs }: RunABTestFlowProps) { + const engine = useMemo(() => createJobEngine(new ConfigIO()), []); + const [flow, setFlow] = useState({ name: 'loading' }); + + useEffect(() => { + if (flow.name !== 'loading') return; + let cancelled = false; + + void (async () => { + try { + await validateAwsCredentials(); + } catch (err) { + if (!cancelled) setFlow({ name: 'creds-error', message: getErrorMessage(err) }); + return; + } + + try { + const { resources, region } = await loadResources(); + if (cancelled) return; + if (resources.gateways.length === 0) { + setFlow({ + name: 'error', + message: 'No deployed gateway found. Run `agentcore add gateway` and `agentcore deploy` first.', + }); + return; + } + setFlow({ name: 'wizard', resources, region }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [flow.name]); + + // Fire-and-forget: start the A/B test job, then show the Started confirmation screen. + useEffect(() => { + if (flow.name !== 'starting') return; + let cancelled = false; + const { config } = flow; + + void (async () => { + try { + const opts: StartABTestJobOptions = { + name: config.name, + mode: config.mode, + gateway: config.gateway, + agent: config.runtime || undefined, + runtime: config.runtime || undefined, + controlBundle: config.mode === 'config-bundle' ? config.controlBundle : undefined, + controlVersion: config.mode === 'config-bundle' ? config.controlVersion : undefined, + treatmentBundle: config.mode === 'config-bundle' ? config.treatmentBundle : undefined, + treatmentVersion: config.mode === 'config-bundle' ? config.treatmentVersion : undefined, + controlTarget: config.mode === 'target-based' ? config.controlTarget : undefined, + treatmentTarget: config.mode === 'target-based' ? config.treatmentTarget : undefined, + onlineEval: config.mode === 'config-bundle' ? config.onlineEval : undefined, + controlOnlineEval: config.mode === 'target-based' ? config.controlOnlineEval : undefined, + treatmentOnlineEval: config.mode === 'target-based' ? config.treatmentOnlineEval : undefined, + gatewayFilter: config.gatewayFilter.trim() || undefined, + controlWeight: config.controlWeight, + treatmentWeight: config.treatmentWeight, + enableOnCreate: true, + }; + const result = await engine.start('ab-test', opts); + if (cancelled) return; + if (!result.success) { + setFlow({ name: 'error', message: result.error.message }); + return; + } + setFlow({ name: 'started', record: result.record, config }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [flow.name]); // eslint-disable-line react-hooks/exhaustive-deps + + if (flow.name === 'loading') { + return ( + + + + ); + } + + if (flow.name === 'creds-error') { + return ; + } + + if (flow.name === 'wizard') { + return ( + setFlow({ name: 'starting', config })} + onExit={onExit} + /> + ); + } + + if (flow.name === 'starting') { + return ( + + + + ); + } + + if (flow.name === 'started') { + return ( + setFlow({ name: 'loading' })} + onViewJobs={onViewJobs} + onExit={onExit} + /> + ); + } + + return ( + setFlow({ name: 'loading' })} + onExit={onExit} + /> + ); +} + +// ============================================================================ +// Started confirmation view +// ============================================================================ + +interface StartedViewProps { + record: ABTestJobRecord; + config: RunABTestConfig; + onRunAnother: () => void; + onViewJobs?: () => void; + onExit: () => void; +} + +function StartedView({ record, config, onRunAnother, onViewJobs, onExit }: StartedViewProps) { + const actions = [ + { id: 'jobs', title: 'View jobs' }, + { id: 'another', title: 'Run another' }, + { id: 'back', title: 'Back' }, + ]; + + const nav = useListNavigation({ + items: actions, + onSelect: item => { + if (item.id === 'jobs') (onViewJobs ?? onExit)(); + else if (item.id === 'another') onRunAnother(); + else onExit(); + }, + onExit, + isActive: true, + }); + + return ( + + + + + ✓ {record.id} ({record.status}) + + + Name: {config.name} + {' '} + Mode: {config.mode} + {' '} + Gateway: {config.gateway} + + + + Track its progress and results in A/B Test Jobs. + + + + {actions.map((action, idx) => { + const selected = idx === nav.selectedIndex; + return ( + + {selected ? '❯' : ' '} + + {action.title} + + + ); + })} + + + + + ); +} + +// ============================================================================ +// Wizard Component +// ============================================================================ + +interface RunABTestWizardProps { + resources: ABTestResources; + region: string; + onComplete: (config: RunABTestConfig) => void; + onExit: () => void; +} + +function RunABTestWizard({ resources, region, onComplete, onExit }: RunABTestWizardProps) { + const [config, setConfig] = useState({ + mode: 'config-bundle', + name: '', + gateway: resources.gateways[0] ?? '', + controlBundle: '', + controlVersion: 'LATEST', + treatmentBundle: '', + treatmentVersion: 'LATEST', + controlTarget: '', + treatmentTarget: '', + runtime: resources.runtimes[0] ?? '', + controlWeight: 50, + treatmentWeight: 50, + onlineEval: '', + controlOnlineEval: '', + treatmentOnlineEval: '', + gatewayFilter: '', + }); + + const steps = config.mode === 'target-based' ? TARGET_BASED_STEPS : CONFIG_BUNDLE_STEPS; + const [step, setStep] = useState('mode'); + const currentIndex = steps.indexOf(step); + + // Live draft of the optional filter input, so the footer hint can flip submit/skip. + const [filterDraft, setFilterDraft] = useState(config.gatewayFilter); + + const goBack = useCallback(() => { + const prev = steps[currentIndex - 1]; + if (prev) setStep(prev); + else onExit(); + }, [steps, currentIndex, onExit]); + + const goNext = useCallback(() => { + const next = steps[currentIndex + 1]; + if (next) setStep(next); + }, [steps, currentIndex]); + + // ── step item lists ── + const modeItems: SelectableItem[] = useMemo( + () => [ + { id: 'config-bundle', title: 'Config bundle', description: 'Compare two configuration bundle versions' }, + { id: 'target-based', title: 'Target based', description: 'Compare two gateway-target runtime endpoints' }, + ], + [] + ); + const gatewayItems: SelectableItem[] = useMemo( + () => resources.gateways.map(g => ({ id: g, title: g })), + [resources.gateways] + ); + const onlineEvalItems: SelectableItem[] = useMemo( + () => resources.onlineEvalConfigs.map(c => ({ id: c, title: c })), + [resources.onlineEvalConfigs] + ); + + const isStep = (s: RunABTestStep) => step === s; + + const modeNav = useListNavigation({ + items: modeItems, + onSelect: item => { + setConfig(c => ({ ...c, mode: item.id as ABTestMode })); + setStep('gateway'); + }, + onExit, + isActive: isStep('mode'), + }); + + const gatewayNav = useListNavigation({ + items: gatewayItems, + onSelect: item => { + setConfig(c => ({ ...c, gateway: item.id })); + goNext(); + }, + onExit: goBack, + isActive: isStep('gateway'), + }); + + const onlineEvalNav = useListNavigation({ + items: onlineEvalItems, + onSelect: item => { + setConfig(c => ({ ...c, onlineEval: item.id })); + goNext(); + }, + onExit: goBack, + isActive: isStep('onlineEval'), + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(config), + onExit: goBack, + isActive: isStep('confirm'), + }); + + const helpText = isStep('filter') + ? filterDraft.trim() + ? 'Enter submit · Esc back' + : 'Enter to skip · Esc back' + : isStep('control') || isStep('treatment') || isStep('name') + ? HELP_TEXT.TEXT_INPUT + : HELP_TEXT.NAVIGATE_SELECT; + const headerContent = ; + + return ( + + + {isStep('mode') && ( + + )} + + {isStep('gateway') && ( + + )} + + {isStep('control') && ( + ({ id: b.name, title: b.name }))} + targetItems={resources.targets.map(t => ({ id: t, title: t }))} + evalItems={onlineEvalItems} + initialBundle={config.controlBundle} + initialVersion={config.controlVersion} + initialTarget={config.controlTarget} + initialEval={config.controlOnlineEval} + initialWeight={config.controlWeight} + onPartialUpdate={(bundle, version, target, evalCfg) => { + setConfig(c => ({ + ...c, + controlBundle: bundle, + controlVersion: version, + controlTarget: target, + controlOnlineEval: evalCfg, + })); + }} + onComplete={(bundle, version, target, evalCfg, weight) => { + setConfig(c => ({ + ...c, + controlBundle: bundle, + controlVersion: version, + controlTarget: target, + controlOnlineEval: evalCfg, + controlWeight: weight, + treatmentWeight: 100 - weight, + })); + goNext(); + }} + onCancel={goBack} + /> + )} + + {isStep('treatment') && ( + ({ id: b.name, title: b.name }))} + targetItems={resources.targets.map(t => ({ id: t, title: t }))} + evalItems={onlineEvalItems} + initialBundle={config.treatmentBundle} + initialVersion={config.treatmentVersion} + initialTarget={config.treatmentTarget} + initialEval={config.treatmentOnlineEval} + initialWeight={config.treatmentWeight} + onPartialUpdate={(bundle, version, target, evalCfg) => { + setConfig(c => ({ + ...c, + treatmentBundle: bundle, + treatmentVersion: version, + treatmentTarget: target, + treatmentOnlineEval: evalCfg, + })); + }} + onComplete={(bundle, version, target, evalCfg, weight) => { + setConfig(c => ({ + ...c, + treatmentBundle: bundle, + treatmentVersion: version, + treatmentTarget: target, + treatmentOnlineEval: evalCfg, + treatmentWeight: weight, + controlWeight: 100 - weight, + })); + goNext(); + }} + onCancel={goBack} + /> + )} + + {isStep('onlineEval') && ( + + )} + + {isStep('filter') && ( + + Gateway filter (optional) + Restrict the test to one gateway target path pattern (e.g. "/orders/*"). + Leave blank to have no gateway filter. + + setFilterDraft(value)} + onSubmit={value => { + setConfig(c => ({ ...c, gatewayFilter: value.trim() })); + goNext(); + }} + onCancel={goBack} + /> + + + )} + + {isStep('name') && ( + + A short name for this A/B test. + { + if (value.trim()) { + setConfig(c => ({ ...c, name: value.trim() })); + goNext(); + } + }} + onCancel={goBack} + /> + + )} + + {isStep('confirm') && ( + + )} + + + ); +} + +// ============================================================================ +// Variant Form — one screen per variant with all its fields +// ============================================================================ + +type VariantSubField = 'picker' | 'version' | 'eval' | 'weight'; + +interface VariantFormProps { + variant: 'Control' | 'Treatment'; + mode: ABTestMode; + bundleItems: SelectableItem[]; + targetItems: SelectableItem[]; + evalItems: SelectableItem[]; + initialBundle: string; + initialVersion: string; + initialTarget: string; + initialEval: string; + initialWeight: number; + onPartialUpdate?: (bundle: string, version: string, target: string, evalCfg: string) => void; + onComplete: (bundle: string, version: string, target: string, evalCfg: string, weight: number) => void; + onCancel: () => void; +} + +function VariantForm({ + variant, + mode, + bundleItems, + targetItems, + evalItems, + initialBundle, + initialVersion, + initialTarget, + initialEval, + initialWeight, + onPartialUpdate, + onComplete, + onCancel, +}: VariantFormProps) { + const isConfigBundle = mode === 'config-bundle'; + const fields: VariantSubField[] = useMemo( + () => (isConfigBundle ? ['picker', 'version', 'weight'] : ['picker', 'eval', 'weight']), + [isConfigBundle] + ); + + const [activeField, setActiveField] = useState('picker'); + const [selectedPicker, setSelectedPicker] = useState(isConfigBundle ? initialBundle : initialTarget); + const [version, setVersion] = useState(initialVersion); + const [evalCfg, setEvalCfg] = useState(initialEval); + const [weight, setWeight] = useState(String(initialWeight)); + + const advanceField = useCallback(() => { + const idx = fields.indexOf(activeField); + const next = fields[idx + 1]; + if (next) { + // Save partial state to parent so going back preserves selections + onPartialUpdate?.(isConfigBundle ? selectedPicker : '', version, isConfigBundle ? '' : selectedPicker, evalCfg); + setActiveField(next); + } else { + const w = parseInt(weight, 10); + onComplete( + isConfigBundle ? selectedPicker : '', + version, + isConfigBundle ? '' : selectedPicker, + evalCfg, + isNaN(w) ? initialWeight : w + ); + } + }, [ + activeField, + fields, + weight, + selectedPicker, + version, + evalCfg, + isConfigBundle, + initialWeight, + onComplete, + onPartialUpdate, + ]); + + const goBackField = useCallback(() => { + const idx = fields.indexOf(activeField); + if (idx > 0) setActiveField(fields[idx - 1]!); + else onCancel(); + }, [activeField, fields, onCancel]); + + const pickerItems = isConfigBundle ? bundleItems : targetItems; + + const pickerNav = useListNavigation({ + items: pickerItems, + onSelect: item => { + setSelectedPicker(item.id); + advanceField(); + }, + onExit: goBackField, + isActive: activeField === 'picker', + }); + + const evalNav = useListNavigation({ + items: evalItems, + onSelect: item => { + setEvalCfg(item.id); + advanceField(); + }, + onExit: goBackField, + isActive: activeField === 'eval', + }); + + return ( + + {variant} Variant + {'─'.repeat(30)} + + {/* Summary of completed fields (shown above the active input) */} + {selectedPicker && activeField !== 'picker' && ( + + {isConfigBundle ? 'Bundle:' : 'Target:'} {selectedPicker} + + )} + {version && activeField === 'weight' && isConfigBundle && ( + + Version: {version} + + )} + {evalCfg && activeField === 'weight' && !isConfigBundle && ( + + Online Eval: {evalCfg} + + )} + + {/* Active field */} + + {activeField === 'picker' && ( + + )} + + {activeField === 'version' && ( + + Bundle version (or LATEST): + { + setVersion(value || 'LATEST'); + advanceField(); + }} + onCancel={goBackField} + /> + + )} + + {activeField === 'eval' && ( + + )} + + {activeField === 'weight' && ( + + Traffic weight (0-100): + { + const w = parseInt(value, 10); + if (!isNaN(w) && w >= 0 && w <= 100) { + setWeight(value); + advanceField(); + } + }} + onCancel={goBackField} + customValidation={value => { + const w = parseInt(value, 10); + if (isNaN(w)) return 'Must be a number'; + if (w < 0 || w > 100) return 'Must be between 0 and 100'; + return true; + }} + /> + + )} + + + ); +} diff --git a/src/cli/tui/screens/run-ab-test/index.ts b/src/cli/tui/screens/run-ab-test/index.ts new file mode 100644 index 000000000..0f655333b --- /dev/null +++ b/src/cli/tui/screens/run-ab-test/index.ts @@ -0,0 +1,2 @@ +export { RunABTestFlow } from './RunABTestFlow'; +export { ABTestJobsHistoryScreen } from './ABTestJobsHistoryScreen'; diff --git a/src/cli/tui/screens/run-ab-test/types.ts b/src/cli/tui/screens/run-ab-test/types.ts new file mode 100644 index 000000000..215ebd11d --- /dev/null +++ b/src/cli/tui/screens/run-ab-test/types.ts @@ -0,0 +1,37 @@ +import type { ABTestMode } from '../../../operations/jobs'; + +/** Wizard step ids for the run-as-job A/B test flow. */ +export type RunABTestStep = 'mode' | 'gateway' | 'control' | 'treatment' | 'onlineEval' | 'filter' | 'name' | 'confirm'; + +export interface RunABTestConfig { + mode: ABTestMode; + name: string; + gateway: string; + // config-bundle mode + controlBundle: string; + controlVersion: string; + treatmentBundle: string; + treatmentVersion: string; + // target-based mode + controlTarget: string; + treatmentTarget: string; + runtime: string; + // eval configs + onlineEval: string; + controlOnlineEval: string; + treatmentOnlineEval: string; + // shared + controlWeight: number; + treatmentWeight: number; + /** Single gateway target path pattern; blank means no gateway filter. Applies to both modes. */ + gatewayFilter: string; +} + +/** Deployed resource lists loaded once for the wizard's pickers. */ +export interface ABTestResources { + gateways: string[]; + bundles: { name: string; bundleId: string }[]; + targets: string[]; + runtimes: string[]; + onlineEvalConfigs: string[]; +} diff --git a/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx b/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx index 642759154..96ee676bd 100644 --- a/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx +++ b/src/cli/tui/screens/run-eval/BatchEvalHistoryScreen.tsx @@ -1,10 +1,14 @@ -import type { BatchEvalRunRecord } from '../../../operations/eval/batch-eval-storage'; -import { listBatchEvalRuns } from '../../../operations/eval/batch-eval-storage'; -import { Panel, Screen } from '../../components'; +import { ConfigIO } from '../../../../lib'; +import { validateAwsCredentials } from '../../../aws/account'; +import { getErrorMessage } from '../../../errors'; +import { createJobEngine } from '../../../operations/jobs'; +import type { BatchEvaluationJobRecord } from '../../../operations/jobs'; +import { ErrorPrompt, Panel, Screen } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; -import { Box, Text, useInput, useStdout } from 'ink'; -import React, { useMemo, useState } from 'react'; +import { BatchEvalDetailView, scoreColor, statusColor } from '../job-detail'; +import { Box, Text, useStdout } from 'ink'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; @@ -19,19 +23,6 @@ function formatShortDate(timestamp: string): string { return `${mon} ${day} ${h12}:${m} ${ampm}`; } -function statusColor(status: string): string { - if (status === 'COMPLETED' || status === 'SUCCEEDED') return 'green'; - if (status === 'FAILED') return 'red'; - if (status === 'IN_PROGRESS' || status === 'PENDING') return 'yellow'; - return 'gray'; -} - -function scoreColor(score: number): string { - if (score >= 0.8) return 'green'; - if (score >= 0.5) return 'yellow'; - return 'red'; -} - const CHROME_LINES = 9; // ───────────────────────────────────────────────────────────────────────────── @@ -44,8 +35,8 @@ function BatchEvalListView({ onExit, availableHeight, }: { - records: BatchEvalRunRecord[]; - onSelect: (record: BatchEvalRunRecord) => void; + records: BatchEvaluationJobRecord[]; + onSelect: (record: BatchEvaluationJobRecord) => void; onExit: () => void; availableHeight: number; }) { @@ -68,7 +59,7 @@ function BatchEvalListView({ return ( - Batch Evaluation History + Batch Evaluation Jobs {records.length} batch evaluation{records.length !== 1 ? 's' : ''} @@ -76,48 +67,39 @@ function BatchEvalListView({ {visible.items.map((rec, vIdx) => { const idx = visible.startIdx + vIdx; const selected = idx === nav.selectedIndex; - const date = rec.startedAt ? formatShortDate(rec.startedAt) : 'unknown'; + const date = rec.createdAt ? formatShortDate(rec.createdAt) : 'unknown'; - // Build a short score summary from evaluationResults or results - const summaries = rec.evaluationResults?.evaluatorSummaries; - let scoreText = ''; - if (summaries && summaries.length > 0) { - scoreText = summaries - .map(s => { - const avg = s.statistics?.averageScore; - return avg != null ? avg.toFixed(2) : 'N/A'; - }) - .join(', '); - } else if (rec.results.length > 0) { - const byEval = new Map(); - for (const r of rec.results) { - if (r.score != null) { - const scores = byEval.get(r.evaluatorId) ?? []; - scores.push(r.score); - byEval.set(r.evaluatorId, scores); - } - } - scoreText = [...byEval.entries()] - .map(([, scores]) => (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2)) - .join(', '); - } + // Average score per evaluator, read straight from the API summaries in the record. + const avgScores = (rec.evaluationResults?.evaluatorSummaries ?? []) + .map(s => s.statistics?.averageScore) + .filter((v): v is number => v != null); const datasetLabel = rec.source === 'dataset' && rec.dataset ? ` [${rec.dataset.id}@${rec.dataset.version}]` : ''; return ( - - {selected ? '>' : ' '} + + {selected ? '❯' : ' '} {date.padEnd(16)} {rec.status.padEnd(12)} - {scoreText && {scoreText.padEnd(10)}} + avg + {avgScores.length > 0 ? ( + avgScores.map((avg, i) => ( + + {avg.toFixed(2)} + {i < avgScores.length - 1 ? , : ' '} + + )) + ) : ( + {'—'.padEnd(7)} + )} {rec.name} {datasetLabel && {datasetLabel}} ); })} {visible.startIdx + maxVisible < records.length && ( - {records.length - visible.startIdx - maxVisible} more + ↓ {records.length - visible.startIdx - maxVisible} more )} @@ -125,167 +107,86 @@ function BatchEvalListView({ ); } -// ───────────────────────────────────────────────────────────────────────────── -// Detail view -// ───────────────────────────────────────────────────────────────────────────── - -function BatchEvalDetailView({ record, onBack }: { record: BatchEvalRunRecord; onBack: () => void }) { - useInput((input, key) => { - if (key.escape || input === 'b') { - onBack(); - } - }); - - const evalRes = record.evaluationResults; - const summaries = evalRes?.evaluatorSummaries; - - // Fall back to local grouping when API summaries aren't available - const byEvaluator = useMemo(() => { - if (summaries && summaries.length > 0) return null; - const map = new Map(); - for (const r of record.results) { - const entry = map.get(r.evaluatorId) ?? { scores: [], errors: 0 }; - if (r.error) { - entry.errors++; - } else if (r.score != null) { - entry.scores.push(r.score); - } - map.set(r.evaluatorId, entry); - } - return map; - }, [record.results, summaries]); - - return ( - - - - ID: {record.batchEvaluationId} - - - Name: {record.name} - {' '} - Status: {record.status} - - - Evaluators: {record.evaluators.join(', ')} - - {record.source === 'dataset' && record.dataset && ( - - Dataset: {record.dataset.id} (version: {record.dataset.version}) - - )} - {record.startedAt && ( - - Started: {new Date(record.startedAt).toLocaleString()} - - )} - {record.completedAt && ( - - Completed: {new Date(record.completedAt).toLocaleString()} - - )} - - {evalRes?.totalNumberOfSessions != null && ( - - Sessions: {evalRes.totalNumberOfSessions} total - {evalRes.numberOfSessionsCompleted != null && , {evalRes.numberOfSessionsCompleted} completed} - {evalRes.numberOfSessionsFailed ? , {evalRes.numberOfSessionsFailed} failed : null} - - )} - - {summaries && summaries.length > 0 ? ( - - Scores (0 worst — 1 best): - {summaries.map(s => { - const avg = s.statistics?.averageScore; - const avgStr = avg != null ? avg.toFixed(2) : 'N/A'; - const color = avg != null ? scoreColor(avg) : undefined; - return ( - - {' '} - {s.evaluatorId} - {' '} - {avgStr} - {s.totalFailed ? ({s.totalFailed} failed) : null} - {s.totalEvaluated != null && [{s.totalEvaluated} evaluated]} - - ); - })} - - ) : byEvaluator && byEvaluator.size > 0 ? ( - - Scores (0 worst — 1 best): - {[...byEvaluator.entries()].map(([evalId, { scores, errors }]) => { - const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0; - return ( - - {' '} - {evalId} - {' '} - {avg.toFixed(2)} - {errors > 0 && ({errors} errors)} - - ); - })} - - ) : ( - - No evaluation results available. - - )} - - - Press Esc or B to go back - - - - ); -} - // ───────────────────────────────────────────────────────────────────────────── // Main screen // ───────────────────────────────────────────────────────────────────────────── +type FlowState = + | { name: 'loading' } + | { name: 'creds-error'; message: string } + | { name: 'error'; message: string } + | { name: 'loaded'; records: BatchEvaluationJobRecord[] }; + interface BatchEvalHistoryScreenProps { onExit: () => void; } export function BatchEvalHistoryScreen({ onExit }: BatchEvalHistoryScreenProps) { + const engine = useMemo(() => createJobEngine(new ConfigIO()), []); const { stdout } = useStdout(); const terminalHeight = stdout?.rows ?? 24; const availableHeight = Math.max(6, terminalHeight - CHROME_LINES); - const [selectedRecord, setSelectedRecord] = useState(null); + const [flow, setFlow] = useState({ name: 'loading' }); + const [selectedRecord, setSelectedRecord] = useState(null); - const [records, loaded, error] = useMemo(() => { - try { - return [listBatchEvalRuns(), true, null] as const; - } catch (err) { - return [[] as BatchEvalRunRecord[], true, err instanceof Error ? err.message : String(err)] as const; - } + useEffect(() => { + let cancelled = false; + + void (async () => { + try { + await validateAwsCredentials(); + } catch (err) { + if (!cancelled) setFlow({ name: 'creds-error', message: getErrorMessage(err) }); + return; + } + + try { + const records = await engine.list({ type: 'batch-evaluation' }); + if (!cancelled) setFlow({ name: 'loaded', records }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [engine]); + + // Apply an updated record (e.g. after a stop) into both the selection and the list. + const handleUpdate = useCallback((updated: BatchEvaluationJobRecord) => { + setSelectedRecord(updated); + setFlow(prev => + prev.name === 'loaded' ? { ...prev, records: prev.records.map(r => (r.id === updated.id ? updated : r)) } : prev + ); }, []); - if (!loaded) { + if (flow.name === 'loading') { return ( - - Loading... + + Loading batch evaluation jobs... ); } - if (error) { + if (flow.name === 'creds-error') { + return ; + } + + if (flow.name === 'error') { return ( - - {error} + + {flow.message} ); } - if (records.length === 0) { + if (flow.records.length === 0) { return ( - + - No batch evaluation runs found. + No batch evaluation jobs found. Run a batch evaluation from the TUI or CLI to see results here. @@ -295,17 +196,17 @@ export function BatchEvalHistoryScreen({ onExit }: BatchEvalHistoryScreenProps) const helpText = selectedRecord ? 'Esc/B back to list' : HELP_TEXT.NAVIGATE_SELECT; return ( - + {selectedRecord ? ( - setSelectedRecord(null)} /> + setSelectedRecord(null)} + onUpdate={handleUpdate} + /> ) : ( = { + source: 'Source', agent: 'Agent', evaluators: 'Evaluators', days: 'Lookback', sessions: 'Sessions', 'ground-truth': 'Ground Truth', + 'kms-key-arn': 'KMS Key', name: 'Name', confirm: 'Confirm', }; @@ -82,17 +97,12 @@ type FlowState = dataset?: string; datasetVersion?: string; } - | { - name: 'running'; - config: BatchEvalConfig; - steps: Step[]; - elapsed: number; - batchEvaluationId?: string; - region?: string; - } - | { name: 'results'; result: RunBatchEvaluationCommandResult; savedFilePath?: string } + // Dataset mode only: blocking Phase-1 invocation of dataset scenarios before engine.start. + | { name: 'phase1'; config: BatchEvalConfig; message: string } + | { name: 'starting'; config: BatchEvalConfig } + | { name: 'started'; record: BatchEvaluationJobRecord; config: BatchEvalConfig } | { name: 'creds-error'; message: string } - | { name: 'error'; message: string; logFilePath?: string }; + | { name: 'error'; message: string }; // ============================================================================ // Flow Component @@ -100,29 +110,13 @@ type FlowState = interface RunBatchEvalFlowProps { onExit: () => void; + /** Navigate to the Batch Eval Jobs screen (falls back to onExit when not provided). */ + onViewJobs?: () => void; } -export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { +export function RunBatchEvalFlow({ onExit, onViewJobs }: RunBatchEvalFlowProps) { + const engine = useMemo(() => createJobEngine(new ConfigIO()), []); const [flow, setFlow] = useState({ name: 'loading' }); - const stoppingRef = useRef(false); - - // Handle Esc to stop a running batch evaluation - useInput((_input, key) => { - if (flow.name !== 'running' || !flow.batchEvaluationId || !flow.region || stoppingRef.current) return; - if (key.escape) { - stoppingRef.current = true; - void stopBatchEvaluation({ region: flow.region, batchEvaluationId: flow.batchEvaluationId }).catch(() => { - // Best-effort — the poll loop will pick up the final status - }); - setFlow(prev => { - if (prev.name !== 'running') return prev; - const steps = prev.steps.map(s => - s.status === 'running' ? { ...s, status: 'error' as const, error: 'Stopping...' } : s - ); - return { ...prev, steps }; - }); - } - }); // Load agents and evaluators useEffect(() => { @@ -201,148 +195,160 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { const handleWizardComplete = useCallback( (config: BatchEvalConfig) => { - // Inject dataset info from source-picker selection - if (flow.name === 'wizard' && flow.source === 'dataset') { - config = { ...config, dataset: flow.dataset, datasetVersion: flow.datasetVersion }; - } - stoppingRef.current = false; + // Dataset mode needs a blocking pre-start phase ('phase1': invoke scenarios + ~180s ingestion + // wait) to produce the sessionIds before starting. Historical-traces mode already has its + // sessions (collected in the wizard), so it skips straight to 'starting'. That asymmetry is + // intentional — only dataset mode has pre-start work. const isDataset = flow.name === 'wizard' && flow.source === 'dataset'; - const initialSteps: Step[] = isDataset - ? [ - { label: 'Running dataset scenarios...', status: 'running' }, - { label: 'Starting batch evaluation', status: 'pending' }, - { label: 'Polling for results', status: 'pending' }, - { label: 'Fetching scores', status: 'pending' }, - ] - : [ - { label: 'Starting batch evaluation...', status: 'running' }, - { label: 'Polling for results', status: 'pending' }, - { label: 'Fetching scores', status: 'pending' }, - ]; - setFlow({ name: 'running', config, steps: initialSteps, elapsed: 0 }); + if (isDataset && flow.name === 'wizard') { + // Inject dataset info from source-picker selection + const datasetConfig = { ...config, dataset: flow.dataset, datasetVersion: flow.datasetVersion }; + setFlow({ + name: 'phase1', + config: datasetConfig, + message: `Loading dataset "${flow.dataset ?? 'default'}"...`, + }); + } else { + setFlow({ name: 'starting', config }); + } }, [flow] ); - // Execute batch evaluation + // Phase 1 (dataset mode only): invoke dataset scenarios, build ground-truth metadata, then start. useEffect(() => { - if (flow.name !== 'running') return; + if (flow.name !== 'phase1') return; let cancelled = false; const { config } = flow; - const startTime = Date.now(); - const timer = setInterval(() => { - if (!cancelled) { - setFlow(prev => { - if (prev.name !== 'running') return prev; - return { ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) }; + void (async () => { + try { + const configIO = new ConfigIO(); + const [projectSpec, deployedState, awsTargets] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readDeployedState(), + configIO.resolveAWSDeploymentTargets(), + ]); + + const agentContext = await resolveAgentContext({ + project: projectSpec, + deployedState, + awsTargets, + agentName: config.agent, }); + + if (cancelled) return; + + const datasetResult = await runDatasetScenarios({ + agentContext, + datasetName: config.dataset!, + version: config.datasetVersion, + configBaseDir: configIO.getConfigRoot(), + onProgress: (_phase, msg) => { + if (!cancelled) setFlow(prev => (prev.name === 'phase1' ? { ...prev, message: msg } : prev)); + }, + }); + + if (cancelled) return; + + const successfulResults = datasetResult.scenarioResults.filter(r => r.status === 'success'); + if (successfulResults.length === 0) { + setFlow({ name: 'error', message: 'All scenarios failed during invocation. No sessions to evaluate.' }); + return; + } + + const sessionIds = successfulResults.map(r => r.sessionId); + + // Build sessionMetadata with ground truth from dataset scenarios + const sessionMetadata: SessionMetadataEntry[] = successfulResults.map(r => { + const scenario = datasetResult.scenarios.find(s => s.scenario_id === r.scenarioId); + return { + sessionId: r.sessionId, + testScenarioId: r.scenarioId, + groundTruth: scenario + ? { + inline: { + ...(scenario.assertions ? { assertions: scenario.assertions.map(a => ({ text: a })) } : {}), + ...(scenario.expected_trajectory + ? { expectedTrajectory: { toolNames: scenario.expected_trajectory } } + : {}), + ...(scenario.turns.some(t => t.expectedResponse) + ? { + turns: scenario.turns.map(t => ({ + input: { prompt: t.input }, + ...(t.expectedResponse ? { expectedResponse: { text: t.expectedResponse } } : {}), + })), + } + : {}), + }, + } + : undefined, + }; + }) as SessionMetadataEntry[]; + + setFlow(prev => + prev.name === 'phase1' ? { ...prev, message: 'Waiting 180s for CloudWatch span ingestion...' } : prev + ); + + // Wait for CloudWatch span ingestion before submitting — the batch service + // queries CloudWatch server-side, so we can't poll. Match SDK default (180s). + await sleep(BATCH_INGESTION_DELAY_MS); + if (cancelled) return; + + setFlow({ name: 'starting', config: { ...config, sessionIds, sessionMetadata } }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); } - }, 1000); + })(); + + return () => { + cancelled = true; + }; + }, [flow.name]); // eslint-disable-line react-hooks/exhaustive-deps + + // Fire-and-forget: start the batch evaluation job, then show the Started confirmation screen. + useEffect(() => { + if (flow.name !== 'starting') return; + let cancelled = false; + + const { config } = flow; void (async () => { try { - const result = await runBatchEvaluationCommand({ + const result = await engine.start('batch-evaluation', { agent: config.agent, evaluators: config.evaluators, name: config.name || undefined, sessionIds: config.sessionIds.length > 0 ? config.sessionIds : undefined, lookbackDays: config.days, sessionMetadata: config.sessionMetadata, - dataset: config.dataset, - datasetVersion: config.datasetVersion, - onProgress: (status, _message) => { - if (cancelled) return; - setFlow(prev => { - if (prev.name !== 'running') return prev; - const steps = [...prev.steps]; - if (status === 'running') { - steps[0] = { ...steps[0]!, status: 'success' }; - steps[1] = { ...steps[1]!, status: 'running' }; - } - return { ...prev, steps }; - }); - }, - onStarted: info => { - setFlow(prev => { - if (prev.name !== 'running') return prev; - return { ...prev, batchEvaluationId: info.batchEvaluationId, region: info.region }; - }); - }, + source: config.dataset ? 'dataset' : 'traces', + dataset: config.dataset ? { id: config.dataset, version: config.datasetVersion ?? 'LOCAL' } : undefined, + kmsKeyArn: config.kmsKeyArn || undefined, }); - clearInterval(timer); if (cancelled) return; - // Save results locally - let savedFilePath: string | undefined; - if (result.success) { - try { - const datasetInfo = config.dataset - ? { - source: 'dataset' as const, - dataset: { id: config.dataset, version: config.datasetVersion ?? 'LOCAL' }, - } - : {}; - savedFilePath = saveBatchEvalRun({ result, ...datasetInfo }); - } catch { - // Non-fatal - } - } - if (!result.success) { - setFlow(prev => { - if (prev.name !== 'running') return prev; - const steps = prev.steps.map(s => - s.status === 'running' ? { ...s, status: 'error' as const, error: result.error.message } : s - ); - return { ...prev, steps }; - }); - await new Promise(resolve => setTimeout(resolve, 2000)); - if (cancelled) return; - setFlow({ - name: 'error', - message: result.error?.message ?? 'Batch evaluation failed', - logFilePath: result.logFilePath, - }); + setFlow({ name: 'error', message: result.error.message }); return; } - // Mark all steps success - setFlow(prev => { - if (prev.name !== 'running') return prev; - const steps = prev.steps.map(s => ({ ...s, status: 'success' as const })); - return { ...prev, steps }; - }); - - setFlow({ name: 'results', result, savedFilePath }); + setFlow({ name: 'started', record: result.record, config }); } catch (err) { - clearInterval(timer); - if (!cancelled) { - const errorMsg = getErrorMessage(err); - setFlow(prev => { - if (prev.name !== 'running') return prev; - const steps = prev.steps.map(s => - s.status === 'running' ? { ...s, status: 'error' as const, error: errorMsg } : s - ); - return { ...prev, steps }; - }); - await new Promise(resolve => setTimeout(resolve, 2000)); - setFlow({ name: 'error', message: errorMsg }); - } + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); } })(); return () => { cancelled = true; - clearInterval(timer); }; }, [flow.name]); // eslint-disable-line react-hooks/exhaustive-deps if (flow.name === 'loading') { return ( - + ); @@ -389,37 +395,34 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { ); } - if (flow.name === 'running') { - const minutes = Math.floor(flow.elapsed / 60); - const seconds = flow.elapsed % 60; - const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; - + if (flow.name === 'phase1') { return ( - + - - - Agent: {flow.config.agent} - {' '} - Evaluators: {flow.config.evaluatorNames.join(', ')} - {' '} - ({timeStr}) - - - This may take a few minutes... - {flow.batchEvaluationId && Press Esc to stop the evaluation} + + Phase 1: invoking dataset scenarios... + ); } - if (flow.name === 'results') { + if (flow.name === 'starting') { return ( - + + + ); + } + + if (flow.name === 'started') { + return ( + setFlow({ name: 'loading' })} + onViewJobs={onViewJobs} onExit={onExit} /> ); @@ -428,13 +431,79 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { return ( setFlow({ name: 'loading' })} onExit={onExit} /> ); } +// ============================================================================ +// Started confirmation view +// ============================================================================ + +interface StartedViewProps { + record: BatchEvaluationJobRecord; + config: BatchEvalConfig; + onRunAnother: () => void; + onViewJobs?: () => void; + onExit: () => void; +} + +function StartedView({ record, config, onRunAnother, onViewJobs, onExit }: StartedViewProps) { + const actions = [ + { id: 'jobs', title: 'View jobs' }, + { id: 'another', title: 'Run another' }, + { id: 'back', title: 'Back' }, + ]; + + const nav = useListNavigation({ + items: actions, + onSelect: item => { + if (item.id === 'jobs') (onViewJobs ?? onExit)(); + else if (item.id === 'another') onRunAnother(); + else onExit(); + }, + onExit, + isActive: true, + }); + + return ( + + + + + ✓ {record.id} ({record.status}) + + + Agent: {config.agent} + {' '} + Evaluators: {config.evaluatorNames.join(', ')} + + + + When it completes, view it in Batch Eval Jobs. + + + + {actions.map((action, idx) => { + const selected = idx === nav.selectedIndex; + return ( + + {selected ? '❯' : ' '} + + {action.title} + + + ); + })} + + + + + ); +} + // ============================================================================ // Wizard Component // ============================================================================ @@ -460,11 +529,13 @@ function BatchEvalWizard({ const isDatasetMode = source === 'dataset'; const allSteps = useMemo(() => { if (isDatasetMode) { - return skipAgent ? ['evaluators', 'name', 'confirm'] : ['agent', 'evaluators', 'name', 'confirm']; + return skipAgent + ? ['evaluators', 'kms-key-arn', 'name', 'confirm'] + : ['agent', 'evaluators', 'kms-key-arn', 'name', 'confirm']; } return skipAgent - ? ['evaluators', 'days', 'sessions', 'ground-truth', 'name', 'confirm'] - : ['agent', 'evaluators', 'days', 'sessions', 'ground-truth', 'name', 'confirm']; + ? ['evaluators', 'days', 'sessions', 'ground-truth', 'kms-key-arn', 'name', 'confirm'] + : ['agent', 'evaluators', 'days', 'sessions', 'ground-truth', 'kms-key-arn', 'name', 'confirm']; }, [skipAgent, isDatasetMode]); const [step, setStep] = useState(allSteps[0]!); @@ -476,6 +547,7 @@ function BatchEvalWizard({ sessionIds: [], groundTruthFile: '', sessionMetadata: undefined, + kmsKeyArn: '', name: '', }); @@ -523,6 +595,7 @@ function BatchEvalWizard({ const isDaysStep = step === 'days'; const isSessionsStep = step === 'sessions'; const isGroundTruthStep = step === 'ground-truth'; + const isKmsKeyArnStep = step === 'kms-key-arn'; const isNameStep = step === 'name'; const isConfirmStep = step === 'confirm'; @@ -704,10 +777,13 @@ function BatchEvalWizard({ ? HELP_TEXT.TEXT_INPUT : HELP_TEXT.CONFIRM_CANCEL; - const headerContent = ; + // Prepend the breadcrumb-only 'source' step so the wizard header matches the source-picker's + // (it renders as a completed step here). 'source' is intentionally absent from navigable allSteps. + const displaySteps = useMemo(() => ['source', ...allSteps], [allSteps]); + const headerContent = ; return ( - + {isAgentStep && ( )} + {isKmsKeyArnStep && ( + { + setConfig(c => ({ ...c, kmsKeyArn: value })); + goNext(); + }} + onCancel={() => goBack()} + customValidation={value => { + if (!value) return true; + if (!isValidKmsKeyArn(value)) { + return 'Invalid KMS key ARN (e.g. arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012)'; + } + return true; + }} + /> + )} + {isNameStep && ( Optional — leave blank for auto-generated name. @@ -885,6 +982,7 @@ function BatchEvalWizard({ ] : []), ]), + ...(config.kmsKeyArn ? [{ label: 'KMS Key ARN', value: config.kmsKeyArn }] : []), ...(config.name ? [{ label: 'Name', value: config.name }] : []), ]} /> @@ -921,7 +1019,6 @@ function BatchEvalSourcePicker({ useEffect(() => { void (async () => { try { - const { ConfigIO } = await import('../../../../lib'); const configIO = new ConfigIO(); const spec = await configIO.readProjectSpec(); setDatasets( @@ -1043,218 +1140,85 @@ function BatchEvalSourcePicker({ isActive: step === 'version' && !loadingVersions, }); + // Breadcrumb-only header so the picker shares the wizard's chrome (border + step indicator). + // 'source' is the active step; the remaining steps are a representative preview (the actual + // step list is finalized once a mode is chosen and the wizard takes over). + const pickerSteps: BatchEvalStep[] = ['source', 'evaluators', 'name', 'confirm']; + const pickerHeader = ; + if (step === 'version') { return ( (datasets.length > 1 ? setStep('dataset') : setStep('source'))} + headerContent={pickerHeader} > - - Select version for {selectedDataset}: - {loadingVersions ? ( - - ) : ( - <> - {versionItems.map((item, i) => ( - - {i === versionNav.selectedIndex ? : ' '} - {item.title} - — {item.description} - - ))} - {'\n'}↑↓ Enter select · Esc back - - )} - + + + Select version for {selectedDataset}: + {loadingVersions ? ( + + ) : ( + <> + {versionItems.map((item, i) => ( + + {i === versionNav.selectedIndex ? : ' '} + {item.title} + — {item.description} + + ))} + {'\n'}↑↓ Enter select · Esc back + + )} + + ); } if (step === 'dataset') { return ( - setStep('source')}> + setStep('source')} headerContent={pickerHeader}> + + + Select dataset: + {datasetItems.map((item, i) => ( + + {i === datasetNav.selectedIndex ? : ' '} + {item.title} + {item.description && — {item.description}} + + ))} + {'\n'}↑↓ Enter select · Esc back + + + + ); + } + + return ( + + - Select dataset: - {datasetItems.map((item, i) => ( + Evaluation source: + {sourceItems.map((item, i) => ( - {i === datasetNav.selectedIndex ? : ' '} - {item.title} - {item.description && — {item.description}} + {i === sourceNav.selectedIndex ? : ' '} + {item.title} + — {item.description} ))} {'\n'}↑↓ Enter select · Esc back - - ); - } - - return ( - - - Evaluation source: - {sourceItems.map((item, i) => ( - - {i === sourceNav.selectedIndex ? : ' '} - {item.title} - — {item.description} - - ))} - {'\n'}↑↓ Enter select · Esc back - + ); } // ============================================================================ -// Results View +// Helpers // ============================================================================ -function scoreColor(score: number): string { - if (score >= 0.8) return 'green'; - if (score >= 0.5) return 'yellow'; - return 'red'; -} - -interface ResultsViewProps { - result: RunBatchEvaluationCommandResult; - savedFilePath?: string; - onRunAnother: () => void; - onExit: () => void; -} - -function ResultsView({ result, savedFilePath, onRunAnother, onExit }: ResultsViewProps) { - const actions = [ - { id: 'another', title: 'Run another batch evaluation' }, - { id: 'back', title: 'Back' }, - ]; - - const nav = useListNavigation({ - items: actions, - onSelect: item => { - if (item.id === 'another') onRunAnother(); - else onExit(); - }, - onExit, - isActive: true, - }); - - const evalRes = result.evaluationResults; - const summaries = evalRes?.evaluatorSummaries; - - // Fall back to local grouping when API summaries aren't available - const byEvaluator = useMemo(() => { - if (summaries && summaries.length > 0) return null; - const map = new Map(); - for (const r of result.results) { - const group = map.get(r.evaluatorId) ?? []; - group.push(r); - map.set(r.evaluatorId, group); - } - return map; - }, [result.results, summaries]); - - return ( - - - - ✓ Batch evaluation complete - - ID: {result.batchEvaluationId} - {' '} - Status: {result.status} - - {result.name && ( - - Name: {result.name} - - )} - - {evalRes?.totalNumberOfSessions != null && ( - - Sessions: {evalRes.totalNumberOfSessions} total - {evalRes.numberOfSessionsCompleted != null && ( - , {evalRes.numberOfSessionsCompleted} completed - )} - {evalRes.numberOfSessionsFailed ? ( - , {evalRes.numberOfSessionsFailed} failed - ) : null} - - )} - - {summaries && summaries.length > 0 ? ( - - Scores range from 0 (worst) to 1 (best). - {summaries.map(s => { - const avg = s.statistics?.averageScore; - const avgStr = avg != null ? avg.toFixed(2) : 'N/A'; - const color = avg != null ? scoreColor(avg) : undefined; - return ( - - {' '} - {s.evaluatorId} - {' '} - {avgStr} - {s.totalFailed ? ({s.totalFailed} failed) : null} - {s.totalEvaluated != null && [{s.totalEvaluated} evaluated]} - - ); - })} - - ) : byEvaluator && byEvaluator.size > 0 ? ( - - Scores range from 0 (worst) to 1 (best). - {[...byEvaluator.entries()].map(([evalId, evalResults]) => { - const scores = evalResults.filter(r => !r.error).map(r => r.score!); - const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0; - const errors = evalResults.filter(r => r.error).length; - return ( - - {' '} - {evalId} - {' '} - {avg.toFixed(2)} - {errors > 0 && ({errors} errors)} - - ); - })} - - ) : ( - - No evaluation results returned. - - )} - - {savedFilePath && ( - - Results saved to: {savedFilePath} - - )} - {result.logFilePath && ( - - Log: {result.logFilePath} - - )} - - - {actions.map((action, idx) => { - const selected = idx === nav.selectedIndex; - return ( - - {selected ? '❯' : ' '} - - {action.title} - - - ); - })} - - - - - ); +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/src/cli/tui/screens/run-eval/RunIngestFlow.tsx b/src/cli/tui/screens/run-eval/RunIngestFlow.tsx new file mode 100644 index 000000000..4bc072bf1 --- /dev/null +++ b/src/cli/tui/screens/run-eval/RunIngestFlow.tsx @@ -0,0 +1,669 @@ +import { ConfigIO } from '../../../../lib'; +import { getErrorMessage } from '../../../errors'; +import { runKbIngestionByName } from '../../../operations/ingest'; +import type { StartedIngestion } from '../../../operations/ingest'; +import { ConfirmReview, ErrorPrompt, GradientText, Panel, Screen, WizardSelect } from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { Box, Text } from 'ink'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +const SCREEN_TITLE = 'Ingest Knowledge Base'; + +interface KnowledgeBaseInfo { + name: string; +} + +interface DeployedDataSource { + dataSourceId: string; + uri: string; +} + +interface DeployedKb { + knowledgeBaseId: string; + dataSources: DeployedDataSource[]; +} + +interface FlowContext { + /** All knowledge bases declared in agentcore.json */ + knowledgeBases: KnowledgeBaseInfo[]; + /** AWS deployment target names */ + targetNames: string[]; + /** Region per target name */ + regionByTarget: Record; + /** Deployed-state lookup: targetName -> kbName -> deployed kb */ + deployedKbsByTarget: Record>; + /** Raw deployed-state — passed straight back into runKbIngestionByName */ + deployedState: Parameters[0]['deployedState']; +} + +type FlowState = + | { name: 'loading' } + | { name: 'select-kb'; ctx: FlowContext } + | { name: 'select-target'; ctx: FlowContext; kbName: string } + | { name: 'select-scope'; ctx: FlowContext; kbName: string; targetName: string; deployed: DeployedKb } + | { + name: 'select-data-source'; + ctx: FlowContext; + kbName: string; + targetName: string; + deployed: DeployedKb; + } + | { + name: 'confirm'; + ctx: FlowContext; + kbName: string; + targetName: string; + deployed: DeployedKb; + dataSourceUri?: string; + } + | { + name: 'running'; + ctx: FlowContext; + kbName: string; + targetName: string; + deployed: DeployedKb; + dataSourceUri?: string; + progress: string[]; + } + | { name: 'success'; kbName: string; startedJobs: StartedIngestion[] } + | { name: 'error'; message: string; ctx?: FlowContext }; + +interface RunIngestFlowProps { + onExit: () => void; +} + +export function RunIngestFlow({ onExit }: RunIngestFlowProps) { + const [flow, setFlow] = useState({ name: 'loading' }); + + // ── Initial load ───────────────────────────────────────────────────────── + useEffect(() => { + if (flow.name !== 'loading') return; + let cancelled = false; + + void (async () => { + try { + const configIO = new ConfigIO(); + const [project, awsTargets, deployedState] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readAWSDeploymentTargets(), + configIO.readDeployedState().catch(() => ({ targets: {} })), + ]); + + if (cancelled) return; + + const knowledgeBases: KnowledgeBaseInfo[] = (project.knowledgeBases ?? []).map(kb => ({ name: kb.name })); + + if (knowledgeBases.length === 0) { + setFlow({ + name: 'error', + message: 'No knowledge bases found in agentcore.json. Run `agentcore add knowledge-base` first.', + }); + return; + } + + const targetNames = awsTargets.map(t => t.name); + const regionByTarget: Record = {}; + for (const t of awsTargets) regionByTarget[t.name] = t.region; + + if (targetNames.length === 0) { + setFlow({ + name: 'error', + message: 'No AWS deployment targets found in aws-targets.json.', + }); + return; + } + + const deployedKbsByTarget: Record> = {}; + for (const [tname, target] of Object.entries(deployedState.targets ?? {})) { + const kbs = target?.resources?.knowledgeBases ?? {}; + const map: Record = {}; + for (const [kbName, kb] of Object.entries(kbs)) { + map[kbName] = { + knowledgeBaseId: kb.knowledgeBaseId, + dataSources: (kb.dataSources ?? []).map(ds => ({ dataSourceId: ds.dataSourceId, uri: ds.uri })), + }; + } + deployedKbsByTarget[tname] = map; + } + + setFlow({ + name: 'select-kb', + ctx: { + knowledgeBases, + targetNames, + regionByTarget, + deployedKbsByTarget, + deployedState, + }, + }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [flow.name]); + + // ── Run ingestion when entering 'running' ──────────────────────────────── + useEffect(() => { + if (flow.name !== 'running') return; + let cancelled = false; + + const { ctx, kbName, targetName, dataSourceUri } = flow; + const region = ctx.regionByTarget[targetName]; + + void (async () => { + if (!region) { + if (cancelled) return; + setFlow({ name: 'error', message: `Region for target '${targetName}' could not be resolved.`, ctx }); + return; + } + try { + const result = await runKbIngestionByName({ + knowledgeBaseName: kbName, + deployedState: ctx.deployedState, + targetName, + region, + dataSourceUri, + onProgress: msg => { + if (cancelled) return; + setFlow(prev => (prev.name === 'running' ? { ...prev, progress: [...prev.progress, msg] } : prev)); + }, + }); + + if (cancelled) return; + + if (!result.success) { + setFlow({ name: 'error', message: result.error.message, ctx }); + return; + } + setFlow({ name: 'success', kbName, startedJobs: result.startedJobs }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err), ctx }); + } + })(); + + return () => { + cancelled = true; + }; + }, [flow.name]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Renders ────────────────────────────────────────────────────────────── + if (flow.name === 'loading') { + return ( + + + + ); + } + + if (flow.name === 'error') { + return ( + (flow.ctx ? setFlow({ name: 'select-kb', ctx: flow.ctx }) : onExit())} + onExit={onExit} + /> + ); + } + + if (flow.name === 'select-kb') { + return ( + { + const ctx = flow.ctx; + // Auto-skip target picker when only one target is configured + if (ctx.targetNames.length === 1) { + const targetName = ctx.targetNames[0]!; + return advanceAfterTarget(setFlow, ctx, kbName, targetName); + } + setFlow({ name: 'select-target', ctx, kbName }); + }} + onExit={onExit} + /> + ); + } + + if (flow.name === 'select-target') { + return ( + advanceAfterTarget(setFlow, flow.ctx, flow.kbName, targetName)} + onBack={() => setFlow({ name: 'select-kb', ctx: flow.ctx })} + /> + ); + } + + if (flow.name === 'select-scope') { + return ( + + setFlow({ + name: 'confirm', + ctx: flow.ctx, + kbName: flow.kbName, + targetName: flow.targetName, + deployed: flow.deployed, + }) + } + onChooseOne={() => + setFlow({ + name: 'select-data-source', + ctx: flow.ctx, + kbName: flow.kbName, + targetName: flow.targetName, + deployed: flow.deployed, + }) + } + onBack={() => { + if (flow.ctx.targetNames.length > 1) { + setFlow({ name: 'select-target', ctx: flow.ctx, kbName: flow.kbName }); + } else { + setFlow({ name: 'select-kb', ctx: flow.ctx }); + } + }} + /> + ); + } + + if (flow.name === 'select-data-source') { + return ( + + setFlow({ + name: 'confirm', + ctx: flow.ctx, + kbName: flow.kbName, + targetName: flow.targetName, + deployed: flow.deployed, + dataSourceUri: uri, + }) + } + onBack={() => + setFlow({ + name: 'select-scope', + ctx: flow.ctx, + kbName: flow.kbName, + targetName: flow.targetName, + deployed: flow.deployed, + }) + } + /> + ); + } + + if (flow.name === 'confirm') { + return ( + + setFlow({ + name: 'running', + ctx: flow.ctx, + kbName: flow.kbName, + targetName: flow.targetName, + deployed: flow.deployed, + dataSourceUri: flow.dataSourceUri, + progress: [], + }) + } + onBack={() => { + if (flow.dataSourceUri !== undefined) { + setFlow({ + name: 'select-data-source', + ctx: flow.ctx, + kbName: flow.kbName, + targetName: flow.targetName, + deployed: flow.deployed, + }); + } else { + setFlow({ + name: 'select-scope', + ctx: flow.ctx, + kbName: flow.kbName, + targetName: flow.targetName, + deployed: flow.deployed, + }); + } + }} + /> + ); + } + + if (flow.name === 'running') { + return ( + + + + + {flow.progress.length > 0 && ( + + {flow.progress.map((line, i) => ( + + {line} + + ))} + + )} + Bedrock allows one ingestion job per KB at a time. Sit tight while jobs start. + + + + ); + } + + // success + return ; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function advanceAfterTarget( + setFlow: React.Dispatch>, + ctx: FlowContext, + kbName: string, + targetName: string +) { + const deployed = ctx.deployedKbsByTarget[targetName]?.[kbName]; + if (!deployed) { + setFlow({ + name: 'error', + ctx, + message: `Knowledge base '${kbName}' has not been deployed to target '${targetName}'. Run \`agentcore deploy\` first.`, + }); + return; + } + if (deployed.dataSources.length === 0) { + setFlow({ + name: 'error', + ctx, + message: `Knowledge base '${kbName}' has no recorded data sources. Run \`agentcore deploy\` first.`, + }); + return; + } + setFlow({ name: 'select-scope', ctx, kbName, targetName, deployed }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Step components +// ───────────────────────────────────────────────────────────────────────────── + +interface SelectKbStepProps { + ctx: FlowContext; + onSelect: (kbName: string) => void; + onExit: () => void; +} + +function SelectKbStep({ ctx, onSelect, onExit }: SelectKbStepProps) { + const items: SelectableItem[] = useMemo( + () => + ctx.knowledgeBases.map(kb => { + // Show whether KB is deployed to *any* target as a hint + const anyDeployed = Object.values(ctx.deployedKbsByTarget).some(map => kb.name in map); + return { + id: kb.name, + title: kb.name, + description: anyDeployed ? 'deployed' : 'not yet deployed', + }; + }), + [ctx] + ); + + const nav = useListNavigation({ + items, + onSelect: item => onSelect(item.id), + onExit, + isActive: true, + }); + + return ( + + + + + + ); +} + +interface SelectTargetStepProps { + ctx: FlowContext; + kbName: string; + onSelect: (targetName: string) => void; + onBack: () => void; +} + +function SelectTargetStep({ ctx, kbName, onSelect, onBack }: SelectTargetStepProps) { + const items: SelectableItem[] = useMemo( + () => + ctx.targetNames.map(name => ({ + id: name, + title: name, + description: ctx.regionByTarget[name] ?? '', + })), + [ctx] + ); + + const nav = useListNavigation({ + items, + onSelect: item => onSelect(item.id), + onExit: onBack, + isActive: true, + }); + + return ( + + + + + + ); +} + +interface SelectScopeStepProps { + ctx: FlowContext; + kbName: string; + targetName: string; + deployed: DeployedKb; + onAll: () => void; + onChooseOne: () => void; + onBack: () => void; +} + +function SelectScopeStep({ kbName, deployed, onAll, onChooseOne, onBack }: SelectScopeStepProps) { + const items: SelectableItem[] = useMemo( + () => [ + { + id: 'all', + title: 'All data sources', + description: `Start ingestion for all ${deployed.dataSources.length} data source(s) on this KB.`, + }, + { + id: 'one', + title: 'Choose one data source', + description: 'Pick a single data source to ingest.', + }, + ], + [deployed.dataSources.length] + ); + + const nav = useListNavigation({ + items, + onSelect: item => { + if (item.id === 'all') onAll(); + else onChooseOne(); + }, + onExit: onBack, + isActive: true, + }); + + return ( + + + + + + ); +} + +interface SelectDataSourceStepProps { + deployed: DeployedKb; + kbName: string; + onSelect: (uri: string) => void; + onBack: () => void; +} + +function SelectDataSourceStep({ deployed, kbName, onSelect, onBack }: SelectDataSourceStepProps) { + const items: SelectableItem[] = useMemo( + () => + deployed.dataSources.map(ds => ({ + id: ds.uri, + title: ds.uri, + description: ds.dataSourceId, + })), + [deployed] + ); + + const nav = useListNavigation({ + items, + onSelect: item => onSelect(item.id), + onExit: onBack, + isActive: true, + }); + + return ( + + + + + + ); +} + +interface ConfirmStepProps { + kbName: string; + targetName: string; + deployed: DeployedKb; + dataSourceUri?: string; + onConfirm: () => void; + onBack: () => void; +} + +function ConfirmStep({ kbName, targetName, deployed, dataSourceUri, onConfirm, onBack }: ConfirmStepProps) { + // Single-button confirm — Enter to start, Esc back + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: onConfirm, + onExit: onBack, + isActive: true, + }); + + const scope = dataSourceUri + ? `Single data source — ${dataSourceUri}` + : `All data sources (${deployed.dataSources.length})`; + + return ( + + + + + + ); +} + +interface SuccessViewProps { + kbName: string; + startedJobs: StartedIngestion[]; + onExit: () => void; +} + +function SuccessView({ kbName, startedJobs, onExit }: SuccessViewProps) { + const actions = useMemo(() => [{ id: 'back', title: 'Back to Run menu' }], []); + const nav = useListNavigation({ + items: actions, + onSelect: useCallback(() => onExit(), [onExit]), + onExit, + isActive: true, + }); + + return ( + + + + + ✓ Started ingestion for '{kbName}' ({startedJobs.length} job(s)) + + + {startedJobs.map(job => ( + + {' '} + {job.uri} + + {job.ingestionJobId} + + ))} + + Run `agentcore status --type knowledge-base --name {kbName}` to track progress. + + {actions.map((action, idx) => { + const selected = idx === nav.selectedIndex; + return ( + + {selected ? '❯' : ' '} + + {action.title} + + + ); + })} + + + + + ); +} diff --git a/src/cli/tui/screens/run-eval/RunScreen.tsx b/src/cli/tui/screens/run-eval/RunScreen.tsx index a9a797a37..fc3b69f80 100644 --- a/src/cli/tui/screens/run-eval/RunScreen.tsx +++ b/src/cli/tui/screens/run-eval/RunScreen.tsx @@ -7,11 +7,22 @@ import React, { useMemo } from 'react'; interface RunScreenProps { onRunEval: () => void; onRunBatchEval: () => void; + onRunInsights: () => void; onRunRecommendation: () => void; + onRunIngest: () => void; + onRunABTest: () => void; onExit: () => void; } -export function RunScreen({ onRunEval, onRunBatchEval, onRunRecommendation, onExit }: RunScreenProps) { +export function RunScreen({ + onRunEval, + onRunBatchEval, + onRunInsights, + onRunRecommendation, + onRunIngest, + onRunABTest, + onExit, +}: RunScreenProps) { const items: SelectableItem[] = useMemo( () => [ { @@ -24,11 +35,26 @@ export function RunScreen({ onRunEval, onRunBatchEval, onRunRecommendation, onEx title: 'Batch Evaluation', description: 'Run a batch evaluation against agent sessions via CloudWatch.', }, + { + id: 'run-insights', + title: 'Insights [preview]', + description: 'Run failure analysis across agent sessions to detect patterns and root causes.', + }, { id: 'run-recommendation', title: 'Recommendation', description: 'Optimize system prompts or tool descriptions using agent traces.', }, + { + id: 'run-ingest', + title: 'Ingest knowledge base', + description: 'Start an ingestion job for a deployed knowledge base.', + }, + { + id: 'run-ab-test', + title: 'A/B Test', + description: 'Compare two config-bundle or gateway-target variants live through a gateway.', + }, ], [] ); @@ -38,7 +64,10 @@ export function RunScreen({ onRunEval, onRunBatchEval, onRunRecommendation, onEx onSelect: item => { if (item.id === 'run-eval') onRunEval(); else if (item.id === 'run-batch-eval') onRunBatchEval(); + else if (item.id === 'run-insights') onRunInsights(); else if (item.id === 'run-recommendation') onRunRecommendation(); + else if (item.id === 'run-ingest') onRunIngest(); + else if (item.id === 'run-ab-test') onRunABTest(); }, onExit, isActive: true, diff --git a/src/cli/tui/screens/run-eval/index.ts b/src/cli/tui/screens/run-eval/index.ts index 7c56bd639..fc804947f 100644 --- a/src/cli/tui/screens/run-eval/index.ts +++ b/src/cli/tui/screens/run-eval/index.ts @@ -2,4 +2,5 @@ export { BatchEvalHistoryScreen } from './BatchEvalHistoryScreen'; export { RunBatchEvalFlow } from './RunBatchEvalFlow'; export { RunEvalFlow } from './RunEvalFlow'; export { RunEvalScreen } from './RunEvalScreen'; +export { RunIngestFlow } from './RunIngestFlow'; export { RunScreen } from './RunScreen'; diff --git a/src/cli/tui/screens/run-insights/RunInsightsFlow.tsx b/src/cli/tui/screens/run-insights/RunInsightsFlow.tsx new file mode 100644 index 000000000..a043f3552 --- /dev/null +++ b/src/cli/tui/screens/run-insights/RunInsightsFlow.tsx @@ -0,0 +1,148 @@ +import { ConfigIO } from '../../../../lib'; +import type { DeployedState } from '../../../../schema'; +import { getErrorMessage } from '../../../errors'; +import { createJobEngine } from '../../../operations/jobs'; +import type { InsightsJobRecord } from '../../../operations/jobs/shared/types'; +import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; +import { ErrorPrompt, GradientText, SuccessPrompt } from '../../components'; +import { RunInsightsScreen } from './RunInsightsScreen'; +import type { RunInsightsConfig } from './types'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +type FlowState = + | { name: 'loading' } + | { name: 'wizard'; agentNames: string[]; onlineEvalConfigArns: string[] } + | { name: 'submitting' } + | { name: 'success'; record: InsightsJobRecord } + | { name: 'error'; message: string }; + +interface RunInsightsFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onViewJobs?: () => void; +} + +export function RunInsightsFlow({ isInteractive = true, onExit, onBack, onViewJobs }: RunInsightsFlowProps) { + const [flow, setFlow] = useState({ name: 'loading' }); + const engine = useMemo(() => createJobEngine(new ConfigIO()), []); + + useEffect(() => { + if (flow.name !== 'loading') return; + let cancelled = false; + + void (async () => { + try { + const configIO = new ConfigIO(); + const [projectSpec, deployedState] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readDeployedState(), + ]); + if (cancelled) return; + + const agentNames = (projectSpec.runtimes ?? []).map(a => a.name); + if (agentNames.length === 0) { + setFlow({ + name: 'error', + message: 'No agents found in project. Add an agent first with `agentcore add agent`.', + }); + return; + } + + const onlineEvalConfigArns = extractOnlineEvalConfigArns(deployedState); + + setFlow({ name: 'wizard', agentNames, onlineEvalConfigArns }); + } catch (err) { + if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + + return () => { + cancelled = true; + }; + }, [flow.name]); + + useEffect(() => { + if (!isInteractive && flow.name === 'success') { + onExit(); + } + }, [isInteractive, flow.name, onExit]); + + const handleComplete = useCallback( + (config: RunInsightsConfig) => { + setFlow({ name: 'submitting' }); + + void (async () => { + try { + const startResult = await withCommandRunTelemetry('run.job', { job_type: 'insights', has_wait: false }, () => + engine.start('insights', { + agent: config.agent || undefined, + insights: config.insights.length > 0 ? config.insights : ['Builtin.Insight.FailureAnalysis'], + onlineEvalConfigArn: config.source === 'online-eval-config' ? config.onlineEvalConfigArn : undefined, + lookbackDays: config.source === 'agent' ? config.lookbackDays : undefined, + sessionIds: config.sessionIds.length > 0 ? config.sessionIds : undefined, + name: config.name || undefined, + }) + ); + + if (!startResult.success) { + throw startResult.error ?? new Error('Failed to start insights job'); + } + setFlow({ name: 'success', record: startResult.record }); + } catch (err) { + setFlow({ name: 'error', message: getErrorMessage(err) }); + } + })(); + }, + [engine] + ); + + if (flow.name === 'loading' || flow.name === 'submitting') { + return ; + } + + if (flow.name === 'wizard') { + return ( + + ); + } + + if (flow.name === 'success') { + return ( + + ); + } + + return ( + setFlow({ name: 'loading' })} + onExit={onExit} + /> + ); +} + +function extractOnlineEvalConfigArns(deployedState: DeployedState): string[] { + const arns: string[] = []; + for (const target of Object.values(deployedState.targets ?? {})) { + const configs = target?.resources?.onlineEvalConfigs ?? {}; + for (const config of Object.values(configs)) { + if (config.onlineEvaluationConfigArn) { + arns.push(config.onlineEvaluationConfigArn); + } + } + } + return arns; +} diff --git a/src/cli/tui/screens/run-insights/RunInsightsScreen.tsx b/src/cli/tui/screens/run-insights/RunInsightsScreen.tsx new file mode 100644 index 000000000..9384d83f9 --- /dev/null +++ b/src/cli/tui/screens/run-insights/RunInsightsScreen.tsx @@ -0,0 +1,221 @@ +import { + ConfirmReview, + Panel, + Screen, + StepIndicator, + TextInput, + WizardMultiSelect, + WizardSelect, +} from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; +import { AVAILABLE_INSIGHTS, RUN_INSIGHTS_STEP_LABELS, SESSION_MODE_OPTIONS, SOURCE_OPTIONS } from './types'; +import type { RunInsightsConfig } from './types'; +import { useRunInsightsWizard } from './useRunInsightsWizard'; +import React, { useMemo } from 'react'; + +interface RunInsightsScreenProps { + agentNames: string[]; + onlineEvalConfigArns: string[]; + onComplete: (config: RunInsightsConfig) => void; + onExit: () => void; +} + +export function RunInsightsScreen({ agentNames, onlineEvalConfigArns, onComplete, onExit }: RunInsightsScreenProps) { + const wizard = useRunInsightsWizard(agentNames.length); + + const isSourceStep = wizard.step === 'source'; + const isAgentStep = wizard.step === 'agent'; + const isInsightsStep = wizard.step === 'insights'; + const isSessionsStep = wizard.step === 'sessions'; + const isLookbackStep = wizard.step === 'lookbackDays'; + const isConfigArnStep = wizard.step === 'configArn'; + const isNameStep = wizard.step === 'name'; + const isConfirmStep = wizard.step === 'confirm'; + + const sourceItems: SelectableItem[] = useMemo( + () => SOURCE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), + [] + ); + + const agentItems: SelectableItem[] = useMemo(() => agentNames.map(name => ({ id: name, title: name })), [agentNames]); + + const insightItems = useMemo( + () => AVAILABLE_INSIGHTS.map(i => ({ id: i.id, title: i.title, description: i.description })), + [] + ); + + const sessionModeItems: SelectableItem[] = useMemo( + () => SESSION_MODE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), + [] + ); + + const configArnItems: SelectableItem[] = useMemo( + () => onlineEvalConfigArns.map(arn => ({ id: arn, title: arn.split('/').pop() ?? arn })), + [onlineEvalConfigArns] + ); + + const sourceNav = useListNavigation({ + items: sourceItems, + onSelect: item => wizard.setSource(item.id as 'agent' | 'online-eval-config'), + onExit, + isActive: isSourceStep, + }); + + const agentNav = useListNavigation({ + items: agentItems, + onSelect: item => wizard.setAgent(item.id), + onExit: () => wizard.goBack(), + isActive: isAgentStep, + }); + + const insightsNav = useMultiSelectNavigation({ + items: insightItems, + getId: item => item.id, + onConfirm: ids => wizard.setInsights(ids), + onExit: () => wizard.goBack(), + isActive: isInsightsStep, + requireSelection: true, + }); + + const sessionModeNav = useListNavigation({ + items: sessionModeItems, + onSelect: item => wizard.setSessionMode(item.id as 'lookback' | 'specific'), + onExit: () => wizard.goBack(), + isActive: isSessionsStep, + }); + + const configArnNav = useListNavigation({ + items: configArnItems, + onSelect: item => wizard.setOnlineEvalConfigArn(item.id), + onExit: () => wizard.goBack(), + isActive: isConfigArnStep, + }); + + useListNavigation({ + items: [{ id: 'submit', title: 'Start insights job' }], + onSelect: () => onComplete(wizard.config), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + const helpText = isInsightsStep + ? 'Space toggle · Enter confirm · Esc back' + : isNameStep || isLookbackStep + ? 'Enter confirm · Esc back' + : HELP_TEXT.NAVIGATE_SELECT; + + return ( + } + > + + {isSourceStep && ( + + )} + + {isAgentStep && ( + + )} + + {isInsightsStep && ( + + )} + + {isSessionsStep && ( + + )} + + {isLookbackStep && ( + { + const days = parseInt(value, 10); + wizard.setLookbackDays(isNaN(days) || days <= 0 ? 7 : days); + }} + onCancel={() => wizard.goBack()} + /> + )} + + {isConfigArnStep && configArnItems.length > 0 && ( + + )} + + {isConfigArnStep && configArnItems.length === 0 && ( + wizard.setOnlineEvalConfigArn(value)} + onCancel={() => wizard.goBack()} + /> + )} + + {isNameStep && ( + wizard.setName(value)} + onCancel={() => wizard.goBack()} + /> + )} + + {isConfirmStep && ( + AVAILABLE_INSIGHTS.find(i => i.id === id)?.title ?? id) + .join(', '), + }, + { label: 'Sessions', value: `Last ${wizard.config.lookbackDays} days` }, + { label: 'Name', value: wizard.config.name || '(auto-generated)' }, + ] + : [ + { label: 'Source', value: 'Online eval config' }, + { label: 'Config', value: wizard.config.onlineEvalConfigArn.split('/').pop() ?? '' }, + { label: 'Name', value: wizard.config.name || '(auto-generated)' }, + ] + } + /> + )} + + + ); +} diff --git a/src/cli/tui/screens/run-insights/index.ts b/src/cli/tui/screens/run-insights/index.ts new file mode 100644 index 000000000..0a569c50e --- /dev/null +++ b/src/cli/tui/screens/run-insights/index.ts @@ -0,0 +1,2 @@ +export { RunInsightsFlow } from './RunInsightsFlow'; +export { RunInsightsScreen } from './RunInsightsScreen'; diff --git a/src/cli/tui/screens/run-insights/types.ts b/src/cli/tui/screens/run-insights/types.ts new file mode 100644 index 000000000..01dc0fd1b --- /dev/null +++ b/src/cli/tui/screens/run-insights/types.ts @@ -0,0 +1,81 @@ +export type RunInsightsSource = 'agent' | 'online-eval-config'; + +export type RunInsightsSessionMode = 'lookback' | 'specific'; + +export type RunInsightsStep = + | 'source' + | 'agent' + | 'insights' + | 'sessions' + | 'lookbackDays' + | 'configArn' + | 'name' + | 'confirm'; + +export interface RunInsightsConfig { + source: RunInsightsSource; + agent: string; + insights: string[]; + sessionMode: RunInsightsSessionMode; + lookbackDays: number; + sessionIds: string[]; + onlineEvalConfigArn: string; + name: string; +} + +export const RUN_INSIGHTS_STEP_LABELS: Record = { + source: 'Source', + agent: 'Agent', + insights: 'Insights', + sessions: 'Sessions', + lookbackDays: 'Lookback', + configArn: 'Config', + name: 'Name', + confirm: 'Confirm', +}; + +export const DEFAULT_LOOKBACK_DAYS = 7; + +export const AVAILABLE_INSIGHTS = [ + { + id: 'Builtin.Insight.FailureAnalysis', + title: 'Failure Analysis', + description: 'Detect failure patterns and generate root causes', + }, + { + id: 'Builtin.Insight.UserIntent', + title: 'User Intent', + description: 'Classify and cluster user intents from session transcripts', + }, + { + id: 'Builtin.Insight.ExecutionSummary', + title: 'Execution Summary', + description: 'Summarize execution patterns and tool usage across sessions', + }, +]; + +export const SOURCE_OPTIONS = [ + { + id: 'agent' as const, + title: 'Agent (CloudWatch)', + description: "Pull sessions from a deployed agent's log group", + }, + { + id: 'online-eval-config' as const, + title: 'Online eval config', + description: 'Use sessions from an existing online eval config', + }, +]; + +export const SESSION_MODE_OPTIONS = [ + { + id: 'lookback' as const, + title: 'Lookback window', + description: 'Use all sessions within N days', + }, + { + id: 'specific' as const, + title: 'Specific sessions', + description: 'Pick individual session IDs', + }, +]; diff --git a/src/cli/tui/screens/run-insights/useRunInsightsWizard.ts b/src/cli/tui/screens/run-insights/useRunInsightsWizard.ts new file mode 100644 index 000000000..df2a16bc4 --- /dev/null +++ b/src/cli/tui/screens/run-insights/useRunInsightsWizard.ts @@ -0,0 +1,151 @@ +import type { RunInsightsConfig, RunInsightsSessionMode, RunInsightsSource, RunInsightsStep } from './types'; +import { DEFAULT_LOOKBACK_DAYS } from './types'; +import { useCallback, useMemo, useState } from 'react'; + +function getStepsForSource(source: RunInsightsSource, agentCount: number): RunInsightsStep[] { + if (source === 'online-eval-config') { + return ['source', 'configArn', 'name', 'confirm']; + } + if (agentCount <= 1) { + return ['source', 'insights', 'sessions', 'lookbackDays', 'name', 'confirm']; + } + return ['source', 'agent', 'insights', 'sessions', 'lookbackDays', 'name', 'confirm']; +} + +function getDefaultConfig(): RunInsightsConfig { + return { + source: 'agent', + agent: '', + insights: [], + sessionMode: 'lookback', + lookbackDays: DEFAULT_LOOKBACK_DAYS, + sessionIds: [], + onlineEvalConfigArn: '', + name: '', + }; +} + +export function useRunInsightsWizard(agentCount: number) { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState('source'); + + const allSteps = useMemo(() => getStepsForSource(config.source, agentCount), [config.source, agentCount]); + const currentIndex = allSteps.indexOf(step); + + const nextStep = useCallback( + (currentStep: RunInsightsStep): RunInsightsStep | undefined => { + const steps = allSteps; + const idx = steps.indexOf(currentStep); + if (idx + 1 < steps.length) { + return steps[idx + 1]!; + } + return undefined; + }, + [allSteps] + ); + + const goBack = useCallback(() => { + if (currentIndex > 0) { + setStep(allSteps[currentIndex - 1]!); + } + }, [allSteps, currentIndex]); + + const setSource = useCallback( + (source: RunInsightsSource) => { + setConfig(c => ({ ...c, source })); + const steps = getStepsForSource(source, agentCount); + setStep(steps[1]!); + }, + [agentCount] + ); + + const setAgent = useCallback( + (agent: string) => { + setConfig(c => ({ ...c, agent })); + const next = nextStep('agent'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setInsights = useCallback( + (insights: string[]) => { + setConfig(c => ({ ...c, insights })); + const next = nextStep('insights'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setSessionMode = useCallback( + (sessionMode: RunInsightsSessionMode) => { + setConfig(c => ({ ...c, sessionMode })); + if (sessionMode === 'lookback') { + const next = nextStep('sessions'); + if (next) setStep(next); + } else { + const next = nextStep('sessions'); + if (next) setStep(next); + } + }, + [nextStep] + ); + + const setLookbackDays = useCallback( + (lookbackDays: number) => { + setConfig(c => ({ ...c, lookbackDays })); + const next = nextStep('lookbackDays'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setSessionIds = useCallback( + (sessionIds: string[]) => { + setConfig(c => ({ ...c, sessionIds })); + const next = nextStep('lookbackDays'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setOnlineEvalConfigArn = useCallback( + (onlineEvalConfigArn: string) => { + setConfig(c => ({ ...c, onlineEvalConfigArn })); + const next = nextStep('configArn'); + if (next) setStep(next); + }, + [nextStep] + ); + + const setName = useCallback( + (name: string) => { + setConfig(c => ({ ...c, name })); + const next = nextStep('name'); + if (next) setStep(next); + }, + [nextStep] + ); + + const reset = useCallback(() => { + setConfig(getDefaultConfig()); + setStep('source'); + }, []); + + return { + config, + step, + steps: allSteps, + currentIndex, + goBack, + setSource, + setAgent, + setInsights, + setSessionMode, + setLookbackDays, + setSessionIds, + setOnlineEvalConfigArn, + setName, + reset, + }; +} diff --git a/src/cli/tui/screens/view/ViewTypePickerScreen.tsx b/src/cli/tui/screens/view/ViewTypePickerScreen.tsx new file mode 100644 index 000000000..8733640c4 --- /dev/null +++ b/src/cli/tui/screens/view/ViewTypePickerScreen.tsx @@ -0,0 +1,40 @@ +import { Screen, WizardSelect } from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import React, { useMemo } from 'react'; + +type ViewType = 'recommendation' | 'batch-evaluation' | 'ab-test'; + +interface ViewTypePickerScreenProps { + onSelect: (type: ViewType) => void; + onExit: () => void; +} + +export function ViewTypePickerScreen({ onSelect, onExit }: ViewTypePickerScreenProps) { + const items: SelectableItem[] = useMemo( + () => [ + { id: 'recommendation', title: 'Recommendations', description: 'View recommendation job history and results' }, + { + id: 'batch-evaluation', + title: 'Batch Evaluations', + description: 'View batch evaluation job history and results', + }, + { id: 'ab-test', title: 'A/B Tests', description: 'View A/B test job history and results' }, + ], + [] + ); + + const nav = useListNavigation({ + items, + onSelect: item => onSelect(item.id as ViewType), + onExit, + isActive: true, + }); + + return ( + + + + ); +} diff --git a/src/cli/tui/screens/view/index.ts b/src/cli/tui/screens/view/index.ts new file mode 100644 index 000000000..640feac03 --- /dev/null +++ b/src/cli/tui/screens/view/index.ts @@ -0,0 +1 @@ +export { ViewTypePickerScreen } from './ViewTypePickerScreen'; diff --git a/src/cli/tui/screens/web-search/AddWebSearchFlow.tsx b/src/cli/tui/screens/web-search/AddWebSearchFlow.tsx new file mode 100644 index 000000000..09b27c3b7 --- /dev/null +++ b/src/cli/tui/screens/web-search/AddWebSearchFlow.tsx @@ -0,0 +1,88 @@ +import { gatewayTargetPrimitive } from '../../../primitives/registry'; +import { ErrorPrompt } from '../../components'; +import { useExistingGateways, useExistingToolNames } from '../../hooks/useCreateMcp'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddWebSearchScreen } from './AddWebSearchScreen'; +import type { AddWebSearchConfig } from './types'; +import React, { useCallback, useEffect, useState } from 'react'; + +type FlowState = + | { name: 'create-wizard' } + | { name: 'create-success'; toolName: string; gateway: string; excludeDomains?: string[] } + | { name: 'error'; message: string }; + +interface AddWebSearchFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function AddWebSearchFlow({ isInteractive = true, onExit, onBack, onDev, onDeploy }: AddWebSearchFlowProps) { + const [flow, setFlow] = useState({ name: 'create-wizard' }); + const { gateways: existingGateways } = useExistingGateways(); + const { toolNames: existingToolNames } = useExistingToolNames(); + + // In non-interactive mode, exit after success. + useEffect(() => { + if (!isInteractive && flow.name === 'create-success') { + onExit(); + } + }, [isInteractive, flow.name, onExit]); + + const handleComplete = useCallback((config: AddWebSearchConfig) => { + void gatewayTargetPrimitive + .createWebSearchGatewayTarget({ + targetType: 'webSearch', + name: config.name, + gateway: config.gateway, + ...(config.excludeDomains && config.excludeDomains.length > 0 ? { excludeDomains: config.excludeDomains } : {}), + }) + .then((result: { toolName: string }) => { + setFlow({ + name: 'create-success', + toolName: result.toolName, + gateway: config.gateway, + excludeDomains: config.excludeDomains, + }); + }) + .catch((err: unknown) => { + setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unknown error' }); + }); + }, []); + + if (flow.name === 'create-wizard') { + return ( + + ); + } + if (flow.name === 'create-success') { + const excludeSuffix = + flow.excludeDomains && flow.excludeDomains.length > 0 + ? ` Excluded domains: ${flow.excludeDomains.join(', ')}.` + : ''; + return ( + + ); + } + if (flow.name === 'error') { + return ( + + ); + } + return null; +} diff --git a/src/cli/tui/screens/web-search/AddWebSearchScreen.tsx b/src/cli/tui/screens/web-search/AddWebSearchScreen.tsx new file mode 100644 index 000000000..1dcd2f2e2 --- /dev/null +++ b/src/cli/tui/screens/web-search/AddWebSearchScreen.tsx @@ -0,0 +1,158 @@ +import { ToolNameSchema } from '../../../../schema'; +import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { generateUniqueName } from '../../utils'; +import type { AddWebSearchConfig } from './types'; +import { Box, Text } from 'ink'; +import React, { useMemo, useState } from 'react'; + +type Step = 'name' | 'gateway' | 'exclude-domains' | 'confirm'; + +const STEP_LABELS: Record = { + name: 'Name', + gateway: 'Gateway', + 'exclude-domains': 'Exclude domains', + confirm: 'Confirm', +}; + +const STEPS: Step[] = ['name', 'gateway', 'exclude-domains', 'confirm']; + +interface AddWebSearchScreenProps { + onComplete: (config: AddWebSearchConfig) => void; + onExit: () => void; + existingGatewayNames: string[]; + existingToolNames: string[]; +} + +export function AddWebSearchScreen({ + onComplete, + onExit, + existingGatewayNames, + existingToolNames, +}: AddWebSearchScreenProps) { + const [step, setStep] = useState('name'); + const [name, setName] = useState(''); + const [gateway, setGateway] = useState(undefined); + const [excludeDomains, setExcludeDomains] = useState(undefined); + + const isNameStep = step === 'name'; + const isGatewayStep = step === 'gateway'; + const isExcludeDomainsStep = step === 'exclude-domains'; + const isConfirmStep = step === 'confirm'; + + const noGatewaysAvailable = isGatewayStep && existingGatewayNames.length === 0; + + const gatewayItems: SelectableItem[] = useMemo( + () => existingGatewayNames.map(g => ({ id: g, title: g })), + [existingGatewayNames] + ); + + const gatewayNav = useListNavigation({ + items: gatewayItems, + isActive: isGatewayStep && !noGatewaysAvailable, + onSelect: (item: SelectableItem) => { + setGateway(item.id); + setStep('exclude-domains'); + }, + onExit: () => setStep('name'), + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete({ name, gateway: gateway!, excludeDomains }), + onExit: () => setStep('exclude-domains'), + isActive: isConfirmStep, + }); + + const helpText = isGatewayStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; + + const headerContent = ; + + const confirmFields = useMemo( + () => [ + { label: 'Name', value: name }, + { label: 'Gateway', value: gateway ?? '' }, + { + label: 'Exclude domains', + value: excludeDomains && excludeDomains.length > 0 ? excludeDomains.join(', ') : '(none)', + }, + ], + [name, gateway, excludeDomains] + ); + + return ( + + + {isNameStep && ( + { + setName(value); + setStep('gateway'); + }} + onCancel={onExit} + schema={ToolNameSchema} + customValidation={value => !existingToolNames.includes(value) || 'Target name already exists'} + /> + )} + + {isGatewayStep && noGatewaysAvailable && } + + {isGatewayStep && !noGatewaysAvailable && ( + + )} + + {isExcludeDomainsStep && ( + { + const domains = value + .split(',') + .map(d => d.trim()) + .filter(d => d.length > 0); + setExcludeDomains(domains.length > 0 ? domains : undefined); + setStep('confirm'); + }} + onCancel={() => setStep('gateway')} + /> + )} + + {isConfirmStep && } + + + ); +} + +function NoGatewaysMessage() { + return ( + + No gateways found + Run `agentcore add gateway` first, then re-run this command. + + Esc back + + + ); +} diff --git a/src/cli/tui/screens/web-search/index.ts b/src/cli/tui/screens/web-search/index.ts new file mode 100644 index 000000000..94c25c5e4 --- /dev/null +++ b/src/cli/tui/screens/web-search/index.ts @@ -0,0 +1,2 @@ +export { AddWebSearchFlow } from './AddWebSearchFlow'; +export type { AddWebSearchConfig } from './types'; diff --git a/src/cli/tui/screens/web-search/types.ts b/src/cli/tui/screens/web-search/types.ts new file mode 100644 index 000000000..a2dadcb4b --- /dev/null +++ b/src/cli/tui/screens/web-search/types.ts @@ -0,0 +1,10 @@ +/** + * Captured by the AddWebSearchScreen wizard and passed to the Flow, which + * dispatches to gatewayTargetPrimitive.createWebSearchGatewayTarget(). + */ +export interface AddWebSearchConfig { + name: string; + gateway: string; + /** Optional list of domains to exclude from search results. */ + excludeDomains?: string[]; +} diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index db9d4f5e9..4e488ffca 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -79,6 +79,15 @@ export class ResourceNotFoundError extends BaseError { } } +/** + * Error indicating a job (recommendation / batch evaluation) was not found locally or on the service. + */ +export class JobNotFoundError extends BaseError { + constructor(message: string, options?: BaseErrorOptions) { + super(message, { defaultSource: 'user', ...options }); + } +} + /** * Error indicating invalid input or configuration values. */ @@ -291,6 +300,18 @@ export class PollExhaustedError extends BaseError { } } +/** + * Thrown when starting or driving a Bedrock Knowledge Base ingestion job + * fails. Default source is 'service' because most ingestion failures are + * AWS-side (validation, throttling, KB not ready). Pre-flight failures from + * the CLI side (KB not deployed, no data sources recorded) override to 'user'. + */ +export class IngestionError extends BaseError { + constructor(message: string, options?: BaseErrorOptions) { + super(message, { defaultSource: 'service', ...options }); + } +} + export class ShellKickedError extends BaseError { constructor(options?: BaseErrorOptions) { super('Shell session was taken over by another client (close code 4000)', { @@ -308,3 +329,9 @@ export class UserCancellationError extends BaseError { super(`User cancelled`, { defaultSource: 'user', ...options }); } } + +export class ExportHarnessError extends BaseError { + constructor(message: string, options?: BaseErrorOptions) { + super(message, { defaultSource: 'client', ...options }); + } +} diff --git a/src/lib/schemas/io/config-io.ts b/src/lib/schemas/io/config-io.ts index 5338b86ba..9fd6bf8c6 100644 --- a/src/lib/schemas/io/config-io.ts +++ b/src/lib/schemas/io/config-io.ts @@ -122,7 +122,6 @@ export class ConfigIO { const cleaned = { ...data }; if (cleaned.configBundles?.length === 0) delete (cleaned as Record).configBundles; if (cleaned.abTests?.length === 0) delete (cleaned as Record).abTests; - if (cleaned.httpGateways?.length === 0) delete (cleaned as Record).httpGateways; await this.validateAndWrite(filePath, 'AgentCore Project Config', AgentCoreProjectSpecSchema, cleaned); } diff --git a/src/schema/__tests__/constants.test.ts b/src/schema/__tests__/constants.test.ts index 2d8bd3bc9..0c52b8f2c 100644 --- a/src/schema/__tests__/constants.test.ts +++ b/src/schema/__tests__/constants.test.ts @@ -1,4 +1,5 @@ import { + LANGUAGE_FRAMEWORK_MATRIX, ModelProviderSchema, NetworkModeSchema, NodeRuntimeSchema, @@ -8,8 +9,10 @@ import { RuntimeVersionSchema, SDKFrameworkSchema, TargetLanguageSchema, + getFrameworksForLanguage, getSupportedFrameworksForProtocol, getSupportedModelProviders, + isFrameworkSupportedForLanguage, isFrameworkSupportedForProtocol, isModelProviderSupported, isReservedProjectName, @@ -175,6 +178,54 @@ describe('getSupportedFrameworksForProtocol', () => { }); }); +describe('LANGUAGE_FRAMEWORK_MATRIX', () => { + it('defines Python and TypeScript', () => { + expect(Object.keys(LANGUAGE_FRAMEWORK_MATRIX)).toEqual(expect.arrayContaining(['Python', 'TypeScript'])); + }); + + it('Python supports the open-source frameworks but not Vercel AI (TypeScript-only)', () => { + expect(LANGUAGE_FRAMEWORK_MATRIX.Python).toEqual( + expect.arrayContaining(['Strands', 'LangChain_LangGraph', 'GoogleADK', 'OpenAIAgents']) + ); + expect(LANGUAGE_FRAMEWORK_MATRIX.Python).not.toContain('VercelAI'); + }); + + it('TypeScript supports only Strands and Vercel AI', () => { + expect([...LANGUAGE_FRAMEWORK_MATRIX.TypeScript].sort()).toEqual(['Strands', 'VercelAI']); + }); +}); + +describe('getFrameworksForLanguage', () => { + it('returns Python frameworks without Vercel AI', () => { + const frameworks = getFrameworksForLanguage('Python'); + expect(frameworks).toContain('Strands'); + expect(frameworks).not.toContain('VercelAI'); + }); + + it('returns TypeScript frameworks including Vercel AI', () => { + const frameworks = getFrameworksForLanguage('TypeScript'); + expect(frameworks).toContain('Strands'); + expect(frameworks).toContain('VercelAI'); + }); +}); + +describe('isFrameworkSupportedForLanguage', () => { + it('returns true for supported combinations', () => { + expect(isFrameworkSupportedForLanguage('Python', 'Strands')).toBe(true); + expect(isFrameworkSupportedForLanguage('TypeScript', 'VercelAI')).toBe(true); + expect(isFrameworkSupportedForLanguage('TypeScript', 'Strands')).toBe(true); + }); + + it('returns false for Python + Vercel AI (the bug being fixed)', () => { + expect(isFrameworkSupportedForLanguage('Python', 'VercelAI')).toBe(false); + }); + + it('returns false for TypeScript + a Python-only framework', () => { + expect(isFrameworkSupportedForLanguage('TypeScript', 'LangChain_LangGraph')).toBe(false); + expect(isFrameworkSupportedForLanguage('TypeScript', 'GoogleADK')).toBe(false); + }); +}); + describe('isFrameworkSupportedForProtocol', () => { it('returns true for Strands + HTTP', () => { expect(isFrameworkSupportedForProtocol('HTTP', 'Strands')).toBe(true); diff --git a/src/schema/constants.ts b/src/schema/constants.ts index ca8732b45..35d09fc61 100644 --- a/src/schema/constants.ts +++ b/src/schema/constants.ts @@ -208,3 +208,31 @@ export function getSupportedFrameworksForProtocol(protocol: ProtocolMode): reado export function isFrameworkSupportedForProtocol(protocol: ProtocolMode, framework: SDKFramework): boolean { return PROTOCOL_FRAMEWORK_MATRIX[protocol].includes(framework); } + +/** + * Matrix defining which SDK frameworks ship templates for each target language. + * Vercel AI is TypeScript-only; the remaining frameworks are Python-only today. + * Used to keep framework pickers and validation in sync with the templates that + * actually exist under `assets//...`. + */ +export const LANGUAGE_FRAMEWORK_MATRIX = { + Python: ['Strands', 'LangChain_LangGraph', 'GoogleADK', 'OpenAIAgents'], + TypeScript: ['Strands', 'VercelAI'], +} as const satisfies Record; + +/** Languages that scaffold from templates (excludes 'Other', which is BYO-only). */ +export type TemplateLanguage = keyof typeof LANGUAGE_FRAMEWORK_MATRIX; + +/** + * Returns the SDK frameworks that have templates for a given target language. + */ +export function getFrameworksForLanguage(language: TemplateLanguage): readonly SDKFramework[] { + return LANGUAGE_FRAMEWORK_MATRIX[language]; +} + +/** + * Checks if a framework has a template for a given target language. + */ +export function isFrameworkSupportedForLanguage(language: TemplateLanguage, framework: SDKFramework): boolean { + return getFrameworksForLanguage(language).includes(framework); +} diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index c42cac1a6..d224bc86a 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -26,8 +26,6 @@ interface AgentCoreProjectSpec { policyEngines: PolicyEngine[]; // Unique by name — Cedar policy engines configBundles: ConfigBundle[]; // Unique by name — configuration bundles for versioned config abTests: ABTest[]; // Unique by name — A/B test experiments - /** @internal Auto-managed by AB test creation. Do not configure directly. */ - httpGateways: HttpGateway[]; // Unique by name — HTTP gateways bound to a runtime datasets: DatasetSpec[]; // Unique by name — datasets for Dataset Management } @@ -57,7 +55,14 @@ interface NetworkConfig { type MemoryStrategyType = 'SEMANTIC' | 'SUMMARIZATION' | 'USER_PREFERENCE' | 'EPISODIC'; type ModelProvider = 'Bedrock' | 'Gemini' | 'OpenAI' | 'Anthropic'; type EvaluationLevel = 'SESSION' | 'TRACE' | 'TOOL_CALL'; -type GatewayTargetType = 'lambda' | 'mcpServer' | 'openApiSchema' | 'smithyModel' | 'apiGateway' | 'lambdaFunctionArn'; +type GatewayTargetType = + | 'lambda' + | 'mcpServer' + | 'openApiSchema' + | 'smithyModel' + | 'apiGateway' + | 'lambdaFunctionArn' + | 'httpRuntime'; type OutboundAuthType = 'OAUTH' | 'API_KEY' | 'NONE'; type GatewayAuthorizerType = 'NONE' | 'AWS_IAM' | 'CUSTOM_JWT'; type GatewayExceptionLevel = 'NONE' | 'DEBUG'; @@ -221,6 +226,7 @@ interface OnlineEvalConfig { interface AgentCoreGateway { name: string; // @regex ^[0-9a-zA-Z](?:[0-9a-zA-Z-]*[0-9a-zA-Z])?$ @max 100 + protocolType?: 'MCP' | 'None'; description?: string; targets: AgentCoreGatewayTarget[]; // Gateway targets authorizerType?: GatewayAuthorizerType; // default 'NONE' @@ -262,16 +268,22 @@ interface GatewayPolicyEngineConfiguration { // GATEWAY TARGET // ───────────────────────────────────────────────────────────────────────────── +interface HttpRuntimeConfig { + runtime: string; // Reference to a runtime name in spec.runtimes + runtimeEndpoint?: string; // Version alias / qualifier +} + interface AgentCoreGatewayTarget { name: string; targetType: GatewayTargetType; toolDefinitions?: ToolDefinition[]; // Required for 'lambda' targets compute?: ToolComputeConfig; // Required for 'lambda' and scaffold targets - endpoint?: string; // URL — required for external 'mcpServer' targets + endpoint?: string; // URL for 'mcpServer' targets outboundAuth?: OutboundAuth; apiGateway?: ApiGatewayConfig; // Required for 'apiGateway' target type schemaSource?: SchemaSource; // Required for 'openApiSchema' / 'smithyModel' targets lambdaFunctionArn?: LambdaFunctionArnConfig; // Required for 'lambdaFunctionArn' target type + httpRuntime?: HttpRuntimeConfig; // Required for 'httpRuntime' targets } interface OutboundAuth { @@ -407,10 +419,6 @@ interface ABTest { evaluationConfig: { onlineEvaluationConfigArn: string; }; - trafficAllocationConfig?: { - routeOnHeader: { headerName: string }; - }; - maxDurationDays?: number; // @min 1 @max 90 enableOnCreate?: boolean; } @@ -424,15 +432,3 @@ interface ABTestVariant { }; }; } - -// ───────────────────────────────────────────────────────────────────────────── -// HTTP GATEWAY -// ───────────────────────────────────────────────────────────────────────────── - -/** @internal HTTP gateway auto-created when setting up an AB test. */ -interface HttpGateway { - name: string; // @regex ^[a-zA-Z][a-zA-Z0-9-]{0,47}$ @max 48 - description?: string; // @max 200 - runtimeRef: string; // Reference to a runtime name from spec.runtimes - roleArn?: string; // IAM role ARN — auto-created if omitted -} diff --git a/src/schema/llm-compacted/mcp.ts b/src/schema/llm-compacted/mcp.ts index 9e1e8d8a2..cfdd94e6b 100644 --- a/src/schema/llm-compacted/mcp.ts +++ b/src/schema/llm-compacted/mcp.ts @@ -41,6 +41,24 @@ interface AgentCoreGatewayTarget { /** Schema source for openApiSchema / smithyModel targets. */ schemaSource?: { inline: { path: string } } | { s3: { uri: string; bucketOwnerAccountId?: string } }; lambdaFunctionArn?: LambdaFunctionArnConfig; + /** Required for `connector` target type. */ + connectorId?: ConnectorId; + /** + * For bedrock-knowledge-bases connector targets — a project KB name or a + * literal 10-char external KB ID. Mutually exclusive with `knowledgeBaseIds`. + */ + knowledgeBaseId?: string; + /** + * For bedrock-agentic-retrieve connector targets — fan-out list of project + * KB names or literal 10-char external KB IDs. Mutually exclusive with + * `knowledgeBaseId`. + */ + knowledgeBaseIds?: string[]; + /** + * For `webSearch` target type only — domains to exclude from search results. + * Maps to the connector's `domainFilter.exclude` parameterValue at synth. + */ + excludeDomains?: string[]; } interface OutboundAuth { @@ -176,7 +194,18 @@ interface IamPolicyDocument { // ENUMS // ───────────────────────────────────────────────────────────────────────────── -type GatewayTargetType = 'lambda' | 'mcpServer' | 'openApiSchema' | 'smithyModel' | 'apiGateway' | 'lambdaFunctionArn'; +type GatewayTargetType = + | 'lambda' + | 'mcpServer' + | 'openApiSchema' + | 'smithyModel' + | 'apiGateway' + | 'lambdaFunctionArn' + | 'httpRuntime' + | 'connector' + | 'passthrough' + | 'webSearch'; +type ConnectorId = 'bedrock-knowledge-bases' | 'bedrock-agentic-retrieve'; type PythonRuntime = 'PYTHON_3_10' | 'PYTHON_3_11' | 'PYTHON_3_12' | 'PYTHON_3_13' | 'PYTHON_3_14'; type NodeRuntime = 'NODE_18' | 'NODE_20' | 'NODE_22'; type NetworkMode = 'PUBLIC' | 'VPC'; diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 5423d16ab..3796f85cf 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -1,3 +1,4 @@ +import type { DirectoryPath, FilePath } from '../../types/index.js'; import { AgentCoreProjectSpecSchema, CredentialNameSchema, @@ -616,4 +617,225 @@ describe('AgentCoreProjectSpecSchema', () => { }); expect(result.success).toBe(false); }); + + it('httpGateways empty array passes silently', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + httpGateways: [], + }); + expect(result.success).toBe(true); + }); + + it('httpGateways with entries produces migration error', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + httpGateways: [{ name: 'old-gw' }], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('deprecated'))).toBe(true); + } + }); + + it('rejects httpRuntime target on MCP gateway (no protocolType None)', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + agentCoreGateways: [ + { + name: 'mcp-gw', + targets: [ + { + name: 'http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'MyAgent' }, + }, + ], + }, + ], + runtimes: [ + { + name: 'MyAgent', + build: 'CodeZip', + entrypoint: 'main.py' as FilePath, + codeLocation: './src' as DirectoryPath, + runtimeVersion: 'PYTHON_3_12', + protocol: 'HTTP', + }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('protocolType'))).toBe(true); + } + }); + + it('accepts httpRuntime target on gateway with protocolType None', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + agentCoreGateways: [ + { + name: 'http-gw', + protocolType: 'None', + targets: [ + { + name: 'http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'MyAgent' }, + }, + ], + }, + ], + runtimes: [ + { + name: 'MyAgent', + build: 'CodeZip', + entrypoint: 'main.py' as FilePath, + codeLocation: './src' as DirectoryPath, + runtimeVersion: 'PYTHON_3_12', + protocol: 'HTTP', + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('accepts connector target on gateway with protocolType None (HTTP is a superset of MCP)', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + agentCoreGateways: [ + { + name: 'http-gw', + protocolType: 'None', + targets: [ + { + name: 'kb-target', + targetType: 'connector', + connectorId: 'bedrock-knowledge-bases', + knowledgeBaseId: 'ABCDEFGHIJ', + }, + ], + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('accepts mcpServer target on gateway with protocolType None (HTTP is a superset of MCP)', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + agentCoreGateways: [ + { + name: 'http-gw', + protocolType: 'None', + targets: [ + { + name: 'mytool', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + }, + ], + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('rejects httpRuntime target referencing non-existent runtime', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + agentCoreGateways: [ + { + name: 'http-gw', + protocolType: 'None', + targets: [ + { + name: 'http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'NonExistentAgent' }, + }, + ], + }, + ], + runtimes: [], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('unknown runtime'))).toBe(true); + } + }); + + it('rejects httpRuntime target referencing non-existent runtimeEndpoint', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + agentCoreGateways: [ + { + name: 'http-gw', + protocolType: 'None', + targets: [ + { + name: 'http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'MyAgent', runtimeEndpoint: 'NONEXISTENT' }, + }, + ], + }, + ], + runtimes: [ + { + name: 'MyAgent', + build: 'CodeZip', + entrypoint: 'main.py' as FilePath, + codeLocation: './src' as DirectoryPath, + runtimeVersion: 'PYTHON_3_12', + protocol: 'HTTP', + endpoints: { + LIVE: { version: 1, description: 'Live endpoint' }, + }, + }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('does not exist on runtime'))).toBe(true); + } + }); +}); + +describe('AgentCoreProjectSpec — knowledgeBases', () => { + it('defaults knowledgeBases to []', () => { + const result = AgentCoreProjectSpecSchema.parse({ + name: 'TestProj', + version: 1, + }); + expect(result.knowledgeBases).toEqual([]); + }); + + it('accepts a populated knowledgeBases array', () => { + const result = AgentCoreProjectSpecSchema.parse({ + name: 'TestProj', + version: 1, + knowledgeBases: [ + { + name: 'product-docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/docs/' }], + }, + ], + }); + expect(result.knowledgeBases).toHaveLength(1); + expect(result.knowledgeBases[0]?.name).toBe('product-docs'); + expect(result.knowledgeBases[0]?.type).toBe('AgentCoreKnowledgeBase'); + }); + + it('rejects duplicate knowledge base names', () => { + expect(() => + AgentCoreProjectSpecSchema.parse({ + name: 'TestProj', + version: 1, + knowledgeBases: [ + { name: 'docs', dataSources: [{ type: 'S3', uri: 's3://my-bucket/a/' }] }, + { name: 'docs', dataSources: [{ type: 'S3', uri: 's3://my-bucket/b/' }] }, + ], + }) + ).toThrow(/Duplicate knowledge base name/); + }); }); diff --git a/src/schema/schemas/__tests__/deployed-state.test.ts b/src/schema/schemas/__tests__/deployed-state.test.ts index 4387dc63e..97da30a4a 100644 --- a/src/schema/schemas/__tests__/deployed-state.test.ts +++ b/src/schema/schemas/__tests__/deployed-state.test.ts @@ -6,6 +6,7 @@ import { DeployedStateSchema, GatewayDeployedStateSchema, HarnessDeployedStateSchema, + KnowledgeBaseDeployedStateSchema, McpDeployedStateSchema, McpLambdaDeployedStateSchema, McpRuntimeDeployedStateSchema, @@ -109,6 +110,40 @@ describe('MemoryDeployedStateSchema', () => { }); }); +describe('KnowledgeBaseDeployedStateSchema', () => { + it('accepts valid KB state with no sourcesHash', () => { + expect( + KnowledgeBaseDeployedStateSchema.safeParse({ + knowledgeBaseId: 'KB1', + knowledgeBaseArn: 'arn:aws:bedrock:us-east-1:123:knowledge-base/KB1', + dataSources: [], + }).success + ).toBe(true); + }); + + it('accepts valid KB state with sourcesHash', () => { + expect( + KnowledgeBaseDeployedStateSchema.safeParse({ + knowledgeBaseId: 'KB1', + knowledgeBaseArn: 'arn:aws:bedrock:us-east-1:123:knowledge-base/KB1', + dataSources: [{ dataSourceId: 'DS1', uri: 's3://b/d/' }], + sourcesHash: 'a'.repeat(64), + }).success + ).toBe(true); + }); + + it('rejects empty sourcesHash', () => { + expect( + KnowledgeBaseDeployedStateSchema.safeParse({ + knowledgeBaseId: 'KB1', + knowledgeBaseArn: 'arn:aws:bedrock:us-east-1:123:knowledge-base/KB1', + dataSources: [], + sourcesHash: '', + }).success + ).toBe(false); + }); +}); + describe('GatewayDeployedStateSchema', () => { it('accepts valid gateway state', () => { expect( @@ -334,6 +369,40 @@ describe('HarnessDeployedStateSchema', () => { }); expect(result.success).toBe(true); }); + + it('accepts a harness deployed-state with harnessVersion', () => { + const result = HarnessDeployedStateSchema.safeParse({ + harnessId: 'abc123', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:123:harness/abc123', + roleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + status: 'READY', + harnessVersion: 3, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.harnessVersion).toBe(3); + }); + + it('accepts a harness deployed-state without harnessVersion (backwards compatible)', () => { + const result = HarnessDeployedStateSchema.safeParse({ + harnessId: 'abc123', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:123:harness/abc123', + roleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + status: 'READY', + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.harnessVersion).toBeUndefined(); + }); + + it('rejects a harnessVersion of 0 (must be >= 1)', () => { + const result = HarnessDeployedStateSchema.safeParse({ + harnessId: 'abc123', + harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:123:harness/abc123', + roleArn: 'arn:aws:iam::123456789012:role/HarnessRole', + status: 'READY', + harnessVersion: 0, + }); + expect(result.success).toBe(false); + }); }); describe('createValidatedDeployedStateSchema', () => { diff --git a/src/schema/schemas/__tests__/mcp-defs.test.ts b/src/schema/schemas/__tests__/mcp-defs.test.ts index f411ca99a..8c04de27d 100644 --- a/src/schema/schemas/__tests__/mcp-defs.test.ts +++ b/src/schema/schemas/__tests__/mcp-defs.test.ts @@ -9,7 +9,6 @@ import { describe, expect, it } from 'vitest'; describe('ToolNameSchema', () => { it('accepts valid names', () => { expect(ToolNameSchema.safeParse('myTool').success).toBe(true); - expect(ToolNameSchema.safeParse('get_user').success).toBe(true); expect(ToolNameSchema.safeParse('search-results').success).toBe(true); }); @@ -20,6 +19,10 @@ describe('ToolNameSchema', () => { expect(ToolNameSchema.safeParse('my.tool').success).toBe(false); }); + it('rejects underscores', () => { + expect(ToolNameSchema.safeParse('get_user').success).toBe(false); + }); + it('enforces 128-char boundary', () => { expect(ToolNameSchema.safeParse('a'.repeat(128)).success).toBe(true); expect(ToolNameSchema.safeParse('a'.repeat(129)).success).toBe(false); diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index 0ab33c2a4..392e3bdca 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -11,6 +11,7 @@ import { ApiGatewayConfigSchema, GatewayExceptionLevelSchema, GatewayTargetTypeSchema, + HttpRuntimeConfigSchema, LambdaFunctionArnConfigSchema, McpImplLanguageSchema, RuntimeConfigSchema, @@ -29,6 +30,10 @@ describe('GatewayTargetTypeSchema', () => { 'smithyModel', 'apiGateway', 'lambdaFunctionArn', + 'httpRuntime', + 'connector', + 'passthrough', + 'webSearch', ]); }); @@ -1055,3 +1060,303 @@ describe('CustomClaimValidationSchema', () => { expect(result.success).toBe(false); }); }); + +describe('HttpRuntimeConfigSchema', () => { + it('accepts config with runtime only', () => { + const result = HttpRuntimeConfigSchema.safeParse({ runtime: 'my-agent' }); + expect(result.success).toBe(true); + }); + + it('accepts config with runtime and runtimeEndpoint', () => { + const result = HttpRuntimeConfigSchema.safeParse({ runtime: 'my-agent', runtimeEndpoint: 'LIVE' }); + expect(result.success).toBe(true); + }); + + it('rejects when runtime is missing', () => { + const result = HttpRuntimeConfigSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects extra fields (strict)', () => { + const result = HttpRuntimeConfigSchema.safeParse({ runtime: 'my-agent', extra: 'not-allowed' }); + expect(result.success).toBe(false); + }); +}); + +describe('AgentCoreGatewayTargetSchema with httpRuntime', () => { + it('accepts valid httpRuntime target with httpRuntime object', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts httpRuntime target with runtimeEndpoint', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent', runtimeEndpoint: 'LIVE' }, + }); + expect(result.success).toBe(true); + }); + + it('rejects httpRuntime target without httpRuntime object', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-http-target', + targetType: 'httpRuntime', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('httpRuntime'))).toBe(true); + } + }); + + it('rejects httpRuntime target with endpoint set (should use httpRuntime.runtimeEndpoint)', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent' }, + endpoint: 'https://example.com/runtime', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('httpRuntime.runtimeEndpoint'))).toBe(true); + } + }); + + it('rejects httpRuntime target with compute', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent' }, + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('compute'))).toBe(true); + } + }); + + it('rejects httpRuntime target with apiGateway config', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent' }, + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { toolFilters: [{ filterPath: '/*', methods: ['GET'] }] }, + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('apiGateway'))).toBe(true); + } + }); + + it('rejects httpRuntime target with lambdaFunctionArn config', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent' }, + lambdaFunctionArn: { + lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', + toolSchemaFile: './tools.json', + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('lambdaFunctionArn'))).toBe(true); + } + }); + + it('rejects httpRuntime target with toolDefinitions', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent' }, + toolDefinitions: [{ name: 'myTool', description: 'A tool', inputSchema: { type: 'object' as const } }], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('toolDefinitions'))).toBe(true); + } + }); + + it('accepts httpRuntime target with OAUTH outbound auth', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent' }, + outboundAuth: { type: 'OAUTH', credentialName: 'my-cred' }, + }); + expect(result.success).toBe(true); + }); + + it('rejects httpRuntime target with API_KEY outbound auth (not supported)', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-http-target', + targetType: 'httpRuntime', + httpRuntime: { runtime: 'my-agent' }, + outboundAuth: { type: 'API_KEY', credentialName: 'my-cred' }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('API_KEY'))).toBe(true); + } + }); +}); + +describe('AgentCoreGatewayTargetSchema with webSearch', () => { + it('accepts a minimal webSearch target', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'web-search', + targetType: 'webSearch', + }); + expect(result.success).toBe(true); + }); + + it('accepts a webSearch target with excludeDomains', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'web-search', + targetType: 'webSearch', + excludeDomains: ['internal.example.com', 'staging.example.com'], + }); + expect(result.success).toBe(true); + }); + + it('rejects an empty excludeDomains array', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'web-search', + targetType: 'webSearch', + excludeDomains: [], + }); + expect(result.success).toBe(false); + }); + + it('rejects a webSearch target with compute', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'web-search', + targetType: 'webSearch', + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('compute'))).toBe(true); + } + }); + + it('rejects a webSearch target with endpoint', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'web-search', + targetType: 'webSearch', + endpoint: 'https://example.com/mcp', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('endpoint'))).toBe(true); + } + }); + + it('rejects a webSearch target with apiGateway config', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'web-search', + targetType: 'webSearch', + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { toolFilters: [{ filterPath: '/*', methods: ['GET'] }] }, + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('apiGateway'))).toBe(true); + } + }); + + it('rejects a webSearch target with lambdaFunctionArn config', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'web-search', + targetType: 'webSearch', + lambdaFunctionArn: { + lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', + toolSchemaFile: './tools.json', + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('lambdaFunctionArn'))).toBe(true); + } + }); + + it('rejects a webSearch target with schemaSource', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'web-search', + targetType: 'webSearch', + schemaSource: { inline: { path: './schema.json' } }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('schemaSource'))).toBe(true); + } + }); + + it('rejects a webSearch target with connectorId', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'web-search', + targetType: 'webSearch', + connectorId: 'bedrock-knowledge-bases', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.path.includes('connectorId'))).toBe(true); + } + }); + + it('rejects a webSearch target with httpRuntime', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'web-search', + targetType: 'webSearch', + httpRuntime: { runtime: 'my-agent' }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.path.includes('httpRuntime'))).toBe(true); + } + }); + + it('rejects a webSearch target with outboundAuth', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'web-search', + targetType: 'webSearch', + outboundAuth: { type: 'OAUTH', credentialName: 'my-cred' }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.path.includes('outboundAuth'))).toBe(true); + } + }); + + it('rejects excludeDomains on a non-webSearch target', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'mcp', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + excludeDomains: ['internal.example.com'], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.path.includes('excludeDomains'))).toBe(true); + } + }); +}); diff --git a/src/schema/schemas/__tests__/online-eval-config.test.ts b/src/schema/schemas/__tests__/online-eval-config.test.ts new file mode 100644 index 000000000..015c8c4df --- /dev/null +++ b/src/schema/schemas/__tests__/online-eval-config.test.ts @@ -0,0 +1,69 @@ +import { OnlineEvalConfigSchema } from '../primitives/online-eval-config'; +import { describe, expect, it } from 'vitest'; + +describe('OnlineEvalConfigSchema - evaluators and insights', () => { + const baseConfig = { + name: 'TestConfig', + agent: 'MyAgent', + samplingRate: 10, + }; + + it('accepts config with evaluators only', () => { + const config = { ...baseConfig, evaluators: ['Builtin.GoalSuccessRate'] }; + expect(OnlineEvalConfigSchema.safeParse(config).success).toBe(true); + }); + + it('accepts config with insights only', () => { + const config = { ...baseConfig, insights: ['FailureAnalyzer'] }; + expect(OnlineEvalConfigSchema.safeParse(config).success).toBe(true); + }); + + it('rejects config with neither evaluators nor insights', () => { + const result = OnlineEvalConfigSchema.safeParse(baseConfig); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('At least one of evaluators or insights'))).toBe(true); + } + }); + + it('rejects config with both evaluators and insights (preview)', () => { + const config = { ...baseConfig, evaluators: ['Builtin.GoalSuccessRate'], insights: ['FailureAnalyzer'] }; + const result = OnlineEvalConfigSchema.safeParse(config); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Cannot have both evaluators and insights'))).toBe(true); + } + }); + + it('accepts clusteringConfig with valid frequencies', () => { + const config = { + ...baseConfig, + insights: ['FailureAnalyzer'], + clusteringConfig: { frequencies: ['DAILY', 'WEEKLY'] }, + }; + expect(OnlineEvalConfigSchema.safeParse(config).success).toBe(true); + }); + + it('rejects clusteringConfig with more than 3 frequencies', () => { + const config = { + ...baseConfig, + insights: ['FailureAnalyzer'], + clusteringConfig: { frequencies: ['DAILY', 'WEEKLY', 'MONTHLY', 'DAILY'] }, + }; + const result = OnlineEvalConfigSchema.safeParse(config); + expect(result.success).toBe(false); + }); + + it('rejects clusteringConfig without insights', () => { + const config = { + ...baseConfig, + evaluators: ['Builtin.GoalSuccessRate'], + clusteringConfig: { frequencies: ['DAILY'] }, + }; + const result = OnlineEvalConfigSchema.safeParse(config); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('clusteringConfig requires insights'))).toBe(true); + } + }); +}); diff --git a/src/schema/schemas/__tests__/private-endpoint.test.ts b/src/schema/schemas/__tests__/private-endpoint.test.ts new file mode 100644 index 000000000..51f81c9fc --- /dev/null +++ b/src/schema/schemas/__tests__/private-endpoint.test.ts @@ -0,0 +1,232 @@ +import { + CustomJwtAuthorizerConfigSchema, + ManagedVpcResourceSchema, + PrivateEndpointOverrideSchema, + PrivateEndpointSchema, + SelfManagedLatticeResourceSchema, +} from '../auth'; +import { describe, expect, it } from 'vitest'; + +const RCFG = 'rcfg-0123456789abcdefg'; +const RCFG_ARN = 'arn:aws:vpc-lattice:us-west-2:123456789012:resourceconfiguration/rcfg-0123456789abcdefg'; + +describe('SelfManagedLatticeResourceSchema', () => { + it('accepts an rcfg id', () => { + expect(SelfManagedLatticeResourceSchema.safeParse({ resourceConfigurationIdentifier: RCFG }).success).toBe(true); + }); + it('accepts a full VPC Lattice ARN', () => { + expect(SelfManagedLatticeResourceSchema.safeParse({ resourceConfigurationIdentifier: RCFG_ARN }).success).toBe( + true + ); + }); + it('rejects a malformed identifier', () => { + expect(SelfManagedLatticeResourceSchema.safeParse({ resourceConfigurationIdentifier: 'nope' }).success).toBe(false); + }); +}); + +describe('ManagedVpcResourceSchema', () => { + const valid = { + vpcIdentifier: 'vpc-0123456789abcdef0', + subnetIds: ['subnet-0123456789abcdef0'], + endpointIpAddressType: 'IPV4' as const, + }; + + it('accepts required-only', () => { + expect(ManagedVpcResourceSchema.safeParse(valid).success).toBe(true); + }); + it('accepts optional securityGroupIds/tags/routingDomain', () => { + expect( + ManagedVpcResourceSchema.safeParse({ + ...valid, + securityGroupIds: ['sg-0123456789abcdef0', 'sg-0fedcba9876543210'], + tags: { team: 'agentcore' }, + routingDomain: 'example.internal', + }).success + ).toBe(true); + }); + it('rejects missing vpcIdentifier', () => { + const { vpcIdentifier, ...rest } = valid; + void vpcIdentifier; + expect(ManagedVpcResourceSchema.safeParse(rest).success).toBe(false); + }); + it('rejects an empty subnetIds array', () => { + expect(ManagedVpcResourceSchema.safeParse({ ...valid, subnetIds: [] }).success).toBe(false); + }); + it('rejects a bad subnet id', () => { + expect(ManagedVpcResourceSchema.safeParse({ ...valid, subnetIds: ['nope'] }).success).toBe(false); + }); + it('rejects an invalid endpointIpAddressType', () => { + expect(ManagedVpcResourceSchema.safeParse({ ...valid, endpointIpAddressType: 'ipv4' }).success).toBe(false); + }); + it('rejects more than 5 securityGroupIds', () => { + const sgs = Array.from({ length: 6 }, (_, i) => `sg-0123456789abcde${i}0`); + expect(ManagedVpcResourceSchema.safeParse({ ...valid, securityGroupIds: sgs }).success).toBe(false); + }); + it('rejects a bad security group id', () => { + expect(ManagedVpcResourceSchema.safeParse({ ...valid, securityGroupIds: ['nope'] }).success).toBe(false); + }); +}); + +describe('PrivateEndpointSchema (exactly-one-of)', () => { + it('accepts the lattice arm alone', () => { + expect( + PrivateEndpointSchema.safeParse({ selfManagedLatticeResource: { resourceConfigurationIdentifier: RCFG } }).success + ).toBe(true); + }); + it('accepts the managed-vpc arm alone', () => { + expect( + PrivateEndpointSchema.safeParse({ + managedVpcResource: { + vpcIdentifier: 'vpc-0123456789abcdef0', + subnetIds: ['subnet-0123456789abcdef0'], + endpointIpAddressType: 'IPV4', + }, + }).success + ).toBe(true); + }); + it('rejects BOTH arms present', () => { + const result = PrivateEndpointSchema.safeParse({ + selfManagedLatticeResource: { resourceConfigurationIdentifier: RCFG }, + managedVpcResource: { + vpcIdentifier: 'vpc-0123456789abcdef0', + subnetIds: ['subnet-0123456789abcdef0'], + endpointIpAddressType: 'IPV4', + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('exactly one'))).toBe(true); + } + }); + it('rejects NEITHER arm present (empty object)', () => { + expect(PrivateEndpointSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('PrivateEndpointOverrideSchema', () => { + it('accepts a domain + nested private endpoint', () => { + expect( + PrivateEndpointOverrideSchema.safeParse({ + domain: 'api.example.com', + privateEndpoint: { selfManagedLatticeResource: { resourceConfigurationIdentifier: RCFG } }, + }).success + ).toBe(true); + }); + it('rejects a missing domain', () => { + expect( + PrivateEndpointOverrideSchema.safeParse({ + privateEndpoint: { selfManagedLatticeResource: { resourceConfigurationIdentifier: RCFG } }, + }).success + ).toBe(false); + }); +}); + +describe('CustomJwtAuthorizerConfigSchema with PrivateLink fields', () => { + const base = { + discoveryUrl: 'https://idp.example.com/.well-known/openid-configuration', + allowedAudience: ['aud-1'], + }; + + it('accepts a privateEndpoint', () => { + expect( + CustomJwtAuthorizerConfigSchema.safeParse({ + ...base, + privateEndpoint: { selfManagedLatticeResource: { resourceConfigurationIdentifier: RCFG } }, + }).success + ).toBe(true); + }); + const latticeEndpoint = { selfManagedLatticeResource: { resourceConfigurationIdentifier: RCFG } }; + const vpcEndpoint = { + managedVpcResource: { + vpcIdentifier: 'vpc-0123456789abcdef0', + subnetIds: ['subnet-0123456789abcdef0'], + endpointIpAddressType: 'IPV4' as const, + }, + }; + + it('accepts up to 5 privateEndpointOverrides (with a base privateEndpoint)', () => { + const overrides = Array.from({ length: 5 }, (_, i) => ({ + domain: `d${i}.example.com`, + privateEndpoint: latticeEndpoint, + })); + expect( + CustomJwtAuthorizerConfigSchema.safeParse({ + ...base, + privateEndpoint: latticeEndpoint, + privateEndpointOverrides: overrides, + }).success + ).toBe(true); + }); + it('rejects more than 5 privateEndpointOverrides', () => { + const overrides = Array.from({ length: 6 }, (_, i) => ({ + domain: `d${i}.example.com`, + privateEndpoint: latticeEndpoint, + })); + expect( + CustomJwtAuthorizerConfigSchema.safeParse({ + ...base, + privateEndpoint: latticeEndpoint, + privateEndpointOverrides: overrides, + }).success + ).toBe(false); + }); + it('still accepts a config with no PrivateLink fields (backwards compat)', () => { + expect(CustomJwtAuthorizerConfigSchema.safeParse(base).success).toBe(true); + }); + + // ── PrivateEndpointOverrides coupling rules (mirror the AgentCore Identity service) ── + it('rejects privateEndpointOverrides without a base privateEndpoint', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + ...base, + privateEndpointOverrides: [{ domain: 'd.example.com', privateEndpoint: latticeEndpoint }], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('only be used when privateEndpoint is also set'))).toBe( + true + ); + } + }); + it('rejects an override arm that mismatches the base arm (lattice base, vpc override)', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + ...base, + privateEndpoint: latticeEndpoint, + privateEndpointOverrides: [{ domain: 'd.example.com', privateEndpoint: vpcEndpoint }], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('same kind'))).toBe(true); + } + }); + it('rejects an override arm that mismatches the base arm (vpc base, lattice override)', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + ...base, + privateEndpoint: vpcEndpoint, + privateEndpointOverrides: [{ domain: 'd.example.com', privateEndpoint: latticeEndpoint }], + }); + expect(result.success).toBe(false); + }); + it('accepts all-managed-vpc base + overrides', () => { + expect( + CustomJwtAuthorizerConfigSchema.safeParse({ + ...base, + privateEndpoint: vpcEndpoint, + privateEndpointOverrides: [{ domain: 'd.example.com', privateEndpoint: vpcEndpoint }], + }).success + ).toBe(true); + }); + it('rejects duplicate override domains', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + ...base, + privateEndpoint: latticeEndpoint, + privateEndpointOverrides: [ + { domain: 'dup.example.com', privateEndpoint: latticeEndpoint }, + { domain: 'dup.example.com', privateEndpoint: latticeEndpoint }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Duplicate privateEndpointOverride domain'))).toBe(true); + } + }); +}); diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index c4f904e84..b9762179c 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -19,7 +19,7 @@ import { KmsKeyArnSchema, } from './primitives/evaluator'; import { HarnessNameSchema } from './primitives/harness'; -import { HttpGatewaySchema } from './primitives/http-gateway'; +import { KnowledgeBaseSchema } from './primitives/knowledge-base'; import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, @@ -77,8 +77,15 @@ export { ConfigBundleSchema }; export type { ComponentConfiguration, ComponentConfigurationMap, ConfigBundle } from './primitives/config-bundle'; export { ConfigBundleNameSchema, ComponentConfigurationMapSchema } from './primitives/config-bundle'; export { PolicyEngineSchema }; -export type { Policy, PolicyEngine, ValidationMode } from './primitives/policy'; -export { PolicyEngineNameSchema, PolicyNameSchema, PolicySchema, ValidationModeSchema } from './primitives/policy'; +export type { AuthorizationPhase, EnforcementMode, Policy, PolicyEngine, ValidationMode } from './primitives/policy'; +export { + AuthorizationPhaseSchema, + EnforcementModeSchema, + PolicyEngineNameSchema, + PolicyNameSchema, + PolicySchema, + ValidationModeSchema, +} from './primitives/policy'; export { TagsSchema }; export type { Tags } from './primitives/tags'; export { DatasetSchema }; @@ -86,8 +93,6 @@ export { DatasetNameSchema, DatasetSchemaTypeSchema } from './primitives/dataset export type { Dataset, DatasetSchemaType } from './primitives/dataset'; export type { ABTestMode, TargetRef, GatewayFilter, PerVariantOnlineEvaluationConfig } from './primitives/ab-test'; export { ABTestModeSchema, TargetRefSchema, GatewayFilterSchema } from './primitives/ab-test'; -export type { HttpGatewayTarget } from './primitives/http-gateway'; -export { HttpGatewayTargetSchema } from './primitives/http-gateway'; export type { BedrockApiFormat, HarnessApiFormat, @@ -96,6 +101,7 @@ export type { HarnessModel, HarnessModelProvider, HarnessSpec, + ManagedMemoryStrategy, OpenAiApiFormat, } from './primitives/harness'; export { @@ -108,8 +114,24 @@ export { HarnessNameSchema, HarnessSpecSchema, HarnessToolTypeSchema, + ManagedMemoryStrategySchema, validateApiFormat, } from './primitives/harness'; +export type { + KnowledgeBase, + DataSource, + S3DataSource, + ConnectorDataSourceType, + ConnectorFileDataSource, +} from './primitives/knowledge-base'; +export { + KnowledgeBaseNameSchema, + KnowledgeBaseSchema, + S3DataSourceSchema, + DataSourceSchema, + ConnectorDataSourceTypeSchema, + ConnectorFileDataSourceSchema, +} from './primitives/knowledge-base'; export { DEFAULT_AUTO_PAYMENT, DEFAULT_SPEND_LIMIT, @@ -393,6 +415,16 @@ export const AgentCoreProjectSpecSchema = z ) ), + knowledgeBases: z + .array(KnowledgeBaseSchema) + .default([]) + .superRefine( + uniqueBy( + kb => kb.name, + name => `Duplicate knowledge base name: ${name}` + ) + ), + credentials: z .array(CredentialSchema) .default([]) @@ -486,16 +518,6 @@ export const AgentCoreProjectSpecSchema = z ) ), - httpGateways: z - .array(HttpGatewaySchema) - .default([]) - .superRefine( - uniqueBy( - gw => gw.name, - name => `Duplicate HTTP gateway name: ${name}` - ) - ), - harnesses: z .array(HarnessRegistryEntrySchema) .default([]) @@ -520,6 +542,14 @@ export const AgentCoreProjectSpecSchema = z } }), + httpGateways: z + .array(z.unknown()) + .max( + 0, + '"httpGateways" is deprecated. Migrate to agentCoreGateways with protocolType: "None", or use "agentcore import gateway".' + ) + .optional(), + payments: z .array(PaymentManagerSchema) .optional() @@ -537,8 +567,8 @@ export const AgentCoreProjectSpecSchema = z const evaluatorNames = new Set(spec.evaluators.map(e => e.name)); for (const config of spec.onlineEvalConfigs) { - // Validate agent reference - if (!agentNames.has(config.agent)) { + // Validate agent reference (only when agent is specified — custom log groups don't need one) + if (config.agent && !agentNames.has(config.agent)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Online eval config "${config.name}" references unknown agent "${config.agent}"`, @@ -546,7 +576,7 @@ export const AgentCoreProjectSpecSchema = z } // Validate evaluator references - for (const evalName of config.evaluators) { + for (const evalName of config.evaluators ?? []) { // Skip built-in evaluators and ARN references (externally managed) if (evalName.startsWith(BUILTIN_EVALUATOR_PREFIX) || evalName.startsWith(ARN_PREFIX)) continue; if (!evaluatorNames.has(evalName)) { @@ -558,14 +588,28 @@ export const AgentCoreProjectSpecSchema = z } } - // Validate HTTP gateway runtimeRef references - for (const gw of spec.httpGateways ?? []) { - const runtimeExists = spec.runtimes.some(r => r.name === gw.runtimeRef); - if (!runtimeExists) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `HTTP gateway "${gw.name}" references unknown runtime "${gw.runtimeRef}"`, - }); + // Validate httpRuntime target runtime references + for (const gw of spec.agentCoreGateways ?? []) { + for (const target of gw.targets) { + if (target.targetType === 'httpRuntime') { + if (target.httpRuntime?.runtime) { + const runtimeExists = spec.runtimes.some(r => r.name === target.httpRuntime!.runtime); + if (!runtimeExists) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Gateway "${gw.name}" target "${target.name}" references unknown runtime "${target.httpRuntime.runtime}". Check spec.runtimes.`, + }); + } else if (target.httpRuntime.runtimeEndpoint && target.httpRuntime.runtimeEndpoint !== 'DEFAULT') { + const runtime = spec.runtimes.find(r => r.name === target.httpRuntime!.runtime); + if (runtime && !runtime.endpoints?.[target.httpRuntime.runtimeEndpoint]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Gateway "${gw.name}" target "${target.name}" references endpoint "${target.httpRuntime.runtimeEndpoint}" which does not exist on runtime "${target.httpRuntime.runtime}".`, + }); + } + } + } + } } } @@ -576,17 +620,17 @@ export const AgentCoreProjectSpecSchema = z const match = /^\{\{gateway:(.+)\}\}$/.exec(gwField); if (match) { const gwName = match[1]; - const gwExists = (spec.httpGateways ?? []).some(gw => gw.name === gwName); + const gwExists = (spec.agentCoreGateways ?? []).some(gw => gw.name === gwName); if (!gwExists) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `AB test "${test.name}" references gateway "${gwName}" which does not exist in httpGateways`, + message: `AB test "${test.name}" references gateway "${gwName}" which does not exist in agentCoreGateways`, }); } // For target-based AB tests, validate target names exist in the gateway's targets array if (test.mode === 'target-based') { - const gw = (spec.httpGateways ?? []).find(g => g.name === gwName); + const gw = (spec.agentCoreGateways ?? []).find(g => g.name === gwName); if (gw) { const gwTargetNames = new Set((gw.targets ?? []).map(t => t.name)); for (const variant of test.variants) { @@ -604,22 +648,45 @@ export const AgentCoreProjectSpecSchema = z } } - // Validate HTTP gateway target runtimeRef and qualifier references - for (const gw of spec.httpGateways ?? []) { - for (const target of gw.targets ?? []) { - const runtime = spec.runtimes.find(r => r.name === target.runtimeRef); - if (!runtime) { + // Connector gateway target KB reference: a project KB name (entry in + // knowledgeBases[]) or a literal 10-char KB ID (an external KB this + // project does not own). Real KB IDs match ^[A-Z0-9]{10}$; KB names + // start with a letter and may include dashes/underscores. The two + // formats can never collide. + const knowledgeBaseNames = new Set((spec.knowledgeBases ?? []).map(kb => kb.name)); + const REAL_KB_ID_PATTERN = /^[A-Z0-9]{10}$/; + const validateKbReference = (target: { name: string }, value: string, fieldLabel: string): void => { + const looksLikeRealId = REAL_KB_ID_PATTERN.test(value); + if (looksLikeRealId) { + if (knowledgeBaseNames.has(value)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `HTTP gateway "${gw.name}" target "${target.name}" references unknown runtime "${target.runtimeRef}"`, + message: `Connector target "${target.name}" ${fieldLabel} "${value}" looks like a literal KB ID but also matches a knowledgeBases[] entry. Rename the knowledge base or reference it by its project name instead.`, }); - } else if (target.qualifier && target.qualifier !== 'DEFAULT' && !runtime.endpoints?.[target.qualifier]) { + } + } else { + if (!knowledgeBaseNames.has(value)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `HTTP gateway "${gw.name}" target "${target.name}" references qualifier "${target.qualifier}" which is not an endpoint on runtime "${target.runtimeRef}"`, + message: `Connector target "${target.name}" ${fieldLabel} "${value}" does not match any knowledgeBases[] entry. To wire an external KB that this project does not own, use its 10-character KB ID.`, }); } } + }; + + for (const gateway of spec.agentCoreGateways ?? []) { + for (const target of gateway.targets ?? []) { + if (target.targetType !== 'connector') continue; + if (target.connectorId !== 'bedrock-knowledge-bases' && target.connectorId !== 'bedrock-agentic-retrieve') { + continue; + } + if (target.knowledgeBaseId) { + validateKbReference(target, target.knowledgeBaseId, 'knowledgeBaseId'); + } + for (const value of target.knowledgeBaseIds ?? []) { + validateKbReference(target, value, 'knowledgeBaseIds[]'); + } + } } // Validate payment connector credential references diff --git a/src/schema/schemas/auth.ts b/src/schema/schemas/auth.ts index 9b9df72ad..8310a5231 100644 --- a/src/schema/schemas/auth.ts +++ b/src/schema/schemas/auth.ts @@ -1,3 +1,4 @@ +import { TagsSchema } from './primitives/tags'; import { z } from 'zod'; // ============================================================================ @@ -32,6 +33,9 @@ const OidcDiscoveryUrlSchema = z // API-documented patterns (from ClaimMatchValueType and CustomClaimValidationType) const MATCH_VALUE_PATTERN = /^[A-Za-z0-9_.-]+$/; +// OAuth scope token: printable ASCII excluding space (0x20) and double-quote (0x22). +// Mirrors CFN CustomJWTAuthorizerConfiguration.AllowedScopes.items pattern byte-for-byte. +const ALLOWED_SCOPE_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/; const CLAIM_NAME_PATTERN = /^[A-Za-z0-9_.:-]+$/; // Server-side reserved claim names (not regex-documented; API rejects these at deploy time) const RESERVED_CLAIM_NAMES = ['client_id']; @@ -83,6 +87,94 @@ export const CustomClaimValidationSchema = z .strict(); export type CustomClaimValidation = z.infer; +// ── PrivateLink Inbound — private endpoint for reaching the OIDC discovery URL ── +// +// Nested inside CustomJWTAuthorizerConfiguration ("PrivateLink inbound" GA-parity feature): +// when a JWT IdP's discovery/JWKS endpoint is privately hosted, this tells the harness how to +// reach it over a private network. Two mutually-exclusive arms. Patterns match the +// AWS::BedrockAgentCore::Harness CFN spec byte-for-byte. + +// VPC Lattice resource-config id, or its full ARN. Exported so the TUI validators reuse the +// schema regex (single source of truth) instead of hand-rolled checks that drift from it. +export const LATTICE_RESOURCE_CONFIG_PATTERN = + /^((rcfg-[0-9a-z]{17})|(arn:[a-z0-9-]+:vpc-lattice:[a-zA-Z0-9-]+:\d{12}:resourceconfiguration\/rcfg-[0-9a-z]{17}))$/; +export const VPC_ID_PATTERN = /^vpc-(([0-9a-z]{8})|([0-9a-z]{17}))$/; +export const SUBNET_ID_PATTERN = /^subnet-[0-9a-zA-Z]{8,17}$/; +export const SECURITY_GROUP_ID_PATTERN = /^sg-(([0-9a-z]{8})|([0-9a-z]{17}))$/; + +export const EndpointIpAddressTypeSchema = z.enum(['IPV4', 'IPV6']); +export type EndpointIpAddressType = z.infer; + +/** Reach the discovery endpoint via a self-managed VPC Lattice resource configuration. */ +export const SelfManagedLatticeResourceSchema = z + .object({ + resourceConfigurationIdentifier: z + .string() + .min(20) + .max(2048) + .regex(LATTICE_RESOURCE_CONFIG_PATTERN, 'Must be a VPC Lattice resource-config id (rcfg-...) or its ARN'), + }) + .strict(); +export type SelfManagedLatticeResource = z.infer; + +/** Reach the discovery endpoint via a service-managed VPC interface endpoint. */ +export const ManagedVpcResourceSchema = z + .object({ + vpcIdentifier: z.string().regex(VPC_ID_PATTERN, 'Must be a VPC id (vpc-...)'), + subnetIds: z.array(z.string().regex(SUBNET_ID_PATTERN, 'Must be a subnet id (subnet-...)')).min(1), + endpointIpAddressType: EndpointIpAddressTypeSchema, + securityGroupIds: z + .array(z.string().regex(SECURITY_GROUP_ID_PATTERN, 'Must be a security group id (sg-...)')) + .max(5) + .optional(), + tags: TagsSchema.optional(), + routingDomain: z.string().min(3).max(255).optional(), + }) + .strict(); +export type ManagedVpcResource = z.infer; + +/** + * A private endpoint: exactly one of selfManagedLatticeResource or managedVpcResource. + * The CFN spec dropped `oneOf` (contract-test antipattern) and enforces exactly-one structurally; + * we mirror that with a superRefine rather than a discriminated union. + */ +export const PrivateEndpointSchema = z + .object({ + selfManagedLatticeResource: SelfManagedLatticeResourceSchema.optional(), + managedVpcResource: ManagedVpcResourceSchema.optional(), + }) + .strict() + .superRefine((data, ctx) => { + const count = [data.selfManagedLatticeResource, data.managedVpcResource].filter(v => v !== undefined).length; + if (count !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'A private endpoint must set exactly one of selfManagedLatticeResource or managedVpcResource', + }); + } + }); +export type PrivateEndpoint = z.infer; + +/** Maps a specific domain to its own private endpoint (overrides the discovery-URL endpoint for that domain). */ +export const PrivateEndpointOverrideSchema = z + .object({ + domain: z.string().min(1).max(253), + privateEndpoint: PrivateEndpointSchema, + }) + .strict(); +export type PrivateEndpointOverride = z.infer; + +/** + * Which arm a PrivateEndpoint uses. The nested exactly-one-of refine guarantees a single arm, + * so this is read at the authorizer level to enforce the service's "all endpoints same kind" rule. + */ +type PrivateEndpointArm = 'selfManagedLatticeResource' | 'managedVpcResource' | undefined; +function privateEndpointArm(pe: PrivateEndpoint): PrivateEndpointArm { + if (pe.selfManagedLatticeResource) return 'selfManagedLatticeResource'; + if (pe.managedVpcResource) return 'managedVpcResource'; + return undefined; +} + // ── Custom JWT Authorizer Configuration ── /** @@ -100,10 +192,22 @@ export const CustomJwtAuthorizerConfigSchema = z allowedAudience: z.array(z.string().min(1)).optional(), /** List of allowed client IDs */ allowedClients: z.array(z.string().min(1)).optional(), - /** List of allowed scopes */ - allowedScopes: z.array(z.string().min(1)).optional(), + /** List of allowed scopes (printable ASCII, no space/quote; ≤255 chars each — CFN parity) */ + allowedScopes: z + .array( + z + .string() + .min(1) + .max(255) + .regex(ALLOWED_SCOPE_PATTERN, 'Scope must be printable ASCII with no spaces or quotes') + ) + .optional(), /** Custom claim validations */ customClaims: z.array(CustomClaimValidationSchema).min(1).optional(), + /** PrivateLink inbound: how to reach the OIDC discovery endpoint over a private network. */ + privateEndpoint: PrivateEndpointSchema.optional(), + /** Per-domain private-endpoint overrides (≤5). */ + privateEndpointOverrides: z.array(PrivateEndpointOverrideSchema).max(5).optional(), }) .strict() .superRefine((data, ctx) => { @@ -118,6 +222,46 @@ export const CustomJwtAuthorizerConfigSchema = z message: 'At least one of allowedAudience, allowedClients, allowedScopes, or customClaims must be provided', }); } + + // PrivateEndpointOverrides coupling rules — mirror the AgentCore Identity service's deploy-time + // validation so the user fails fast here instead of mid-deploy. + const overrides = data.privateEndpointOverrides ?? []; + if (overrides.length > 0) { + // 1. Overrides require a base privateEndpoint. + if (!data.privateEndpoint) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'privateEndpointOverrides can only be used when privateEndpoint is also set', + path: ['privateEndpointOverrides'], + }); + } else { + // 2. The base endpoint and every override must use the same arm (all self-managed or all service-managed). + const baseArm = privateEndpointArm(data.privateEndpoint); + overrides.forEach((o, i) => { + if (privateEndpointArm(o.privateEndpoint) !== baseArm) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'privateEndpoint and privateEndpointOverrides must all be the same kind — either all selfManagedLatticeResource or all managedVpcResource', + path: ['privateEndpointOverrides', i, 'privateEndpoint'], + }); + } + }); + } + + // 3. Override domains must be unique. + const seen = new Set(); + overrides.forEach((o, i) => { + if (seen.has(o.domain)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate privateEndpointOverride domain: ${o.domain}`, + path: ['privateEndpointOverrides', i, 'domain'], + }); + } + seen.add(o.domain); + }); + } }); export type CustomJwtAuthorizerConfig = z.infer; diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 495dd444f..49d369da2 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -35,10 +35,17 @@ export type MemoryDeployedState = z.infer; // MCP Gateway Deployed State // ============================================================================ +export const GatewayTargetDeployedStateSchema = z.object({ + targetId: z.string().min(1), +}); + +export type GatewayTargetDeployedState = z.infer; + export const GatewayDeployedStateSchema = z.object({ gatewayId: z.string().min(1), gatewayArn: z.string().min(1), gatewayUrl: z.string().optional(), + targets: z.record(z.string(), GatewayTargetDeployedStateSchema).optional(), }); export type GatewayDeployedState = z.infer; @@ -144,9 +151,12 @@ export const HarnessDeployedStateSchema = z.object({ harnessArn: z.string().min(1), roleArn: z.string().min(1), status: z.string().min(1), + /** Read-only harness config version from the CFN `Version` output; service-incremented per update. */ + harnessVersion: z.number().int().min(1).optional(), agentRuntimeArn: z.string().optional(), memoryArn: z.string().optional(), - configHash: z.string().optional(), + /** Which subsystem provisioned this harness. Stamped 'cloudformation' by the CDK deploy path. */ + provisioner: z.enum(['cloudformation']).optional(), }); export type HarnessDeployedState = z.infer; @@ -202,6 +212,41 @@ export const DatasetDeployedStateSchema = z.object({ export type DatasetDeployedState = z.infer; +// ============================================================================ +// Knowledge Base Deployed State +// ============================================================================ + +export const KnowledgeBaseDataSourceDeployedStateSchema = z.object({ + dataSourceId: z.string().min(1), + uri: z.string().min(1), +}); + +export type KnowledgeBaseDataSourceDeployedState = z.infer; + +/** + * Per-target deployed state for a knowledge base. Captures the IDs the + * status command needs to call bedrock-agent for live KB and ingestion state. + * + * `dataSources` is an array (not a record) because the deploy step writes + * them in the same order as the local `dataSources[]` array; the index + * lets us correlate local sources with deployed DSs without extra IDs. + * + * `sourcesHash` is a SHA-256 of the data-source URIs (joined with newlines) + * captured at the time auto-ingestion last fired. The post-deploy ingestion + * hook computes a fresh hash from the current spec and compares — if equal, + * skip ingestion (no changes to ingest). Optional so projects deployed + * before the hook shipped don't fail validation; treated as "ingest needed" + * when absent. + */ +export const KnowledgeBaseDeployedStateSchema = z.object({ + knowledgeBaseId: z.string().min(1), + knowledgeBaseArn: z.string().min(1), + dataSources: z.array(KnowledgeBaseDataSourceDeployedStateSchema).default([]), + sourcesHash: z.string().min(1).optional(), +}); + +export type KnowledgeBaseDeployedState = z.infer; + // ============================================================================ // Configuration Bundle Deployed State // ============================================================================ @@ -228,21 +273,6 @@ export const ABTestDeployedStateSchema = z.object({ export type ABTestDeployedState = z.infer; -// ============================================================================ -// HTTP Gateway Deployed State -// ============================================================================ - -export const HttpGatewayDeployedStateSchema = z.object({ - gatewayId: z.string().min(1), - gatewayArn: z.string().min(1), - gatewayUrl: z.string().optional(), - targetId: z.string().min(1).optional(), - roleArn: z.string().min(1).optional(), - roleCreatedByCli: z.boolean().optional(), -}); - -export type HttpGatewayDeployedState = z.infer; - // ============================================================================ // Runtime Endpoint Deployed State // ============================================================================ @@ -292,14 +322,15 @@ export const DeployedResourceStateSchema = z.object({ runtimes: z.record(z.string(), AgentCoreDeployedStateSchema).optional(), memories: z.record(z.string(), MemoryDeployedStateSchema).optional(), mcp: McpDeployedStateSchema.optional(), + gateways: z.record(z.string(), GatewayDeployedStateSchema).optional(), externallyManaged: ExternallyManagedStateSchema.optional(), credentials: z.record(z.string(), CredentialDeployedStateSchema).optional(), evaluators: z.record(z.string(), EvaluatorDeployedStateSchema).optional(), onlineEvalConfigs: z.record(z.string(), OnlineEvalDeployedStateSchema).optional(), datasets: z.record(z.string(), DatasetDeployedStateSchema).optional(), + knowledgeBases: z.record(z.string(), KnowledgeBaseDeployedStateSchema).optional(), configBundles: z.record(z.string(), ConfigBundleDeployedStateSchema).optional(), abTests: z.record(z.string(), ABTestDeployedStateSchema).optional(), - httpGateways: z.record(z.string(), HttpGatewayDeployedStateSchema).optional(), policyEngines: z.record(z.string(), PolicyEngineDeployedStateSchema).optional(), policies: z.record(z.string(), PolicyDeployedStateSchema).optional(), harnesses: z.record(z.string(), HarnessDeployedStateSchema).optional(), diff --git a/src/schema/schemas/mcp-defs.ts b/src/schema/schemas/mcp-defs.ts index ef0b5f482..72c449bcb 100644 --- a/src/schema/schemas/mcp-defs.ts +++ b/src/schema/schemas/mcp-defs.ts @@ -37,7 +37,8 @@ export const SchemaDefinitionSchema: z.ZodType = z.object({ /** * Tool name validation for CLI input. - * Allows alphanumeric characters, hyphens, and underscores. + * Allows alphanumeric characters and hyphens. Underscores are not permitted + * for gateway targets. * This is a general-purpose schema for tool names that works for both * MCP runtime tools (direct) and gateway target tools. */ @@ -46,8 +47,8 @@ export const ToolNameSchema = z .min(1, 'Tool name is required') .max(128, 'Tool name must be at most 128 characters') .regex( - /^[a-zA-Z][a-zA-Z0-9_-]*$/, - 'Tool name must start with a letter and contain only alphanumeric characters, hyphens, or underscores' + /^[a-zA-Z][a-zA-Z0-9-]*$/, + 'Tool name must start with a letter and contain only alphanumeric characters or hyphens' ); /** diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index 42cd89810..00b86698a 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -17,16 +17,69 @@ export const GatewayTargetTypeSchema = z.enum([ 'smithyModel', 'apiGateway', 'lambdaFunctionArn', + 'httpRuntime', + 'connector', + 'passthrough', + 'webSearch', ]); export type GatewayTargetType = z.infer; +/** + * Target types that use the non-MCP (HTTP) protocol. + * These targets require a gateway with protocolType: "None". + */ +export const NON_MCP_TARGET_TYPES: readonly GatewayTargetType[] = ['httpRuntime', 'passthrough'] as const; + +/** + * Target types that use the MCP protocol. + */ +export const MCP_TARGET_TYPES: readonly GatewayTargetType[] = [ + 'lambda', + 'mcpServer', + 'openApiSchema', + 'smithyModel', + 'apiGateway', + 'lambdaFunctionArn', +] as const; + +// ============================================================================ +// Connector (managed-service gateway target) +// ============================================================================ + +/** + * Managed-service connector identifiers. The L3 maps each one to an operation + * name + Enabled list via CONNECTOR_DEFAULTS. + * + * Spec note: the original DevEx doc (cli-knowledge-bases-devex.md) uses + * `agentic-retrieve`, but the service accepts `bedrock-agentic-retrieve`. The + * latter is canonical. + */ +export const CONNECTOR_ID = { + BEDROCK_KNOWLEDGE_BASES: 'bedrock-knowledge-bases', + BEDROCK_AGENTIC_RETRIEVE: 'bedrock-agentic-retrieve', +} as const; +export const CONNECTOR_ID_VALUES = [ + CONNECTOR_ID.BEDROCK_KNOWLEDGE_BASES, + CONNECTOR_ID.BEDROCK_AGENTIC_RETRIEVE, +] as const; +export const ConnectorIdSchema = z.enum(CONNECTOR_ID_VALUES); +export type ConnectorId = z.infer; + +/** + * Real Bedrock Knowledge Base IDs are 10 uppercase alphanumeric chars. + * KB names follow the standard primitive-name shape (1-48 chars, starts with a letter). + * The two formats can never collide, so a connector target's `knowledgeBaseId` + * field is unambiguously a project KB name or a literal external KB ID. + */ +export const REAL_KB_ID_PATTERN = /^[A-Z0-9]{10}$/; + // ============================================================================ // Gateway Authorization Schemas // ============================================================================ // Auth schemas (GatewayAuthorizerTypeSchema, CustomJwtAuthorizerConfigSchema, etc.) // are defined in ./auth.ts and exported via the barrel (index.ts). -export const OutboundAuthTypeSchema = z.enum(['OAUTH', 'API_KEY', 'NONE']); +export const OutboundAuthTypeSchema = z.enum(['OAUTH', 'API_KEY', 'NONE', 'GATEWAY_IAM_ROLE', 'JWT_PASSTHROUGH']); export type OutboundAuthType = z.infer; export const OutboundAuthSchema = z @@ -34,6 +87,8 @@ export const OutboundAuthSchema = z type: OutboundAuthTypeSchema.default('NONE'), credentialName: z.string().min(1).optional(), scopes: z.array(z.string()).optional(), + service: z.string().min(1).max(64).optional(), + region: z.string().min(1).max(32).optional(), }) .strict(); @@ -60,6 +115,17 @@ export const TARGET_TYPE_AUTH_CONFIG: Record< mcpServer: { authRequired: false, validAuthTypes: ['OAUTH', 'NONE'], iamRoleFallback: false }, lambda: { authRequired: false, validAuthTypes: ['OAUTH', 'NONE'], iamRoleFallback: true }, lambdaFunctionArn: { authRequired: false, validAuthTypes: ['OAUTH', 'NONE'], iamRoleFallback: true }, + httpRuntime: { authRequired: false, validAuthTypes: ['OAUTH', 'NONE'], iamRoleFallback: true }, + // Connector targets call the underlying managed service (Bedrock KB, etc.) + // via the gateway's IAM role. No outbound auth applies. + connector: { authRequired: false, validAuthTypes: [], iamRoleFallback: true }, + passthrough: { + authRequired: true, + validAuthTypes: ['GATEWAY_IAM_ROLE', 'OAUTH', 'JWT_PASSTHROUGH'], + iamRoleFallback: false, + }, + // Amazon Web Search is invoked via the gateway's IAM role. No outbound auth. + webSearch: { authRequired: false, validAuthTypes: [], iamRoleFallback: true }, }; // ============================================================================ @@ -324,6 +390,50 @@ export const SchemaSourceSchema = z.union([ ]); export type SchemaSource = z.infer; +// ============================================================================ +// HTTP Runtime Configuration +// ============================================================================ + +export const HttpRuntimeConfigSchema = z + .object({ + runtime: z.string().min(1), + runtimeEndpoint: z.string().min(1).optional(), + }) + .strict(); + +export type HttpRuntimeConfig = z.infer; + +// ============================================================================ +// Passthrough Target Configuration +// ============================================================================ + +export const StickinessConfigSchema = z + .object({ + identifier: z.string().min(1).max(256), + timeout: z.number().int().min(1).max(86400).optional(), + }) + .strict(); +export type StickinessConfig = z.infer; + +/** + * Passthrough protocol type. HTTP is NOT valid — use CUSTOM for plain HTTP/REST backends. + */ +export const PassthroughProtocolTypeSchema = z.enum(['MCP', 'A2A', 'INFERENCE', 'CUSTOM']); +export type PassthroughProtocolType = z.infer; + +export const PassthroughConfigSchema = z + .object({ + endpoint: z + .string() + .min(1) + .regex(/^https:\/\/[a-zA-Z0-9\-.]+(:[0-9]{1,5})?(\/.*)?$/, 'Must be a valid HTTPS URL'), + /** Protocol type for the passthrough backend. Defaults to CUSTOM (generic HTTP/REST). */ + protocolType: PassthroughProtocolTypeSchema.default('CUSTOM'), + stickinessConfiguration: StickinessConfigSchema.optional(), + }) + .strict(); +export type PassthroughConfig = z.infer; + // ============================================================================ // Gateway Target // ============================================================================ @@ -344,7 +454,7 @@ export const AgentCoreGatewayTargetSchema = z toolDefinitions: z.array(ToolDefinitionSchema).optional(), /** Compute configuration. Required for Lambda/Runtime scaffold targets. */ compute: ToolComputeConfigSchema.optional(), - /** MCP Server endpoint URL. Required for external MCP Server targets. */ + /** Endpoint URL for mcpServer targets. */ endpoint: z.string().url().optional(), /** Outbound auth configuration for the target. */ outboundAuth: OutboundAuthSchema.optional(), @@ -354,6 +464,49 @@ export const AgentCoreGatewayTargetSchema = z schemaSource: SchemaSourceSchema.optional(), /** Lambda Function ARN configuration. Required for lambdaFunctionArn target type. */ lambdaFunctionArn: LambdaFunctionArnConfigSchema.optional(), + /** HTTP Runtime configuration. Required for httpRuntime target type. */ + httpRuntime: HttpRuntimeConfigSchema.optional(), + /** + * Managed-service connector identifier. Required for `connector` target type. + */ + connectorId: ConnectorIdSchema.optional(), + /** + * For `bedrock-knowledge-bases` connector targets: either a project KB + * name (references an entry in `knowledgeBases[]` on the project spec) + * or a literal 10-character KB ID (refers to an external KB this project + * does not own). The L3 disambiguates by regex match. Mutually exclusive + * with `knowledgeBaseIds`. + */ + knowledgeBaseId: z + .string() + .min(1) + .max(48) + .regex(/^[a-zA-Z0-9_-]+$/, 'Must be a KB name (1-48 chars, letters/digits/dash/underscore) or a 10-char KB ID') + .optional(), + /** + * For `bedrock-agentic-retrieve` connector targets only. List of project + * KB names or literal 10-char external KB IDs that this orchestrated + * retriever should fan out across. Each entry is disambiguated the same + * way `knowledgeBaseId` is. Mutually exclusive with `knowledgeBaseId`. + */ + knowledgeBaseIds: z + .array( + z + .string() + .min(1) + .max(48) + .regex(/^[a-zA-Z0-9_-]+$/, 'Each entry must be a KB name (1-48 chars) or a 10-char KB ID') + ) + .min(1) + .optional(), + /** Passthrough configuration. Required for passthrough target type. */ + passthrough: PassthroughConfigSchema.optional(), + /** + * For `webSearch` target type only. Domains to exclude from web search + * results. Maps to the connector's `domainFilter.exclude` parameterValue + * at synth time. + */ + excludeDomains: z.array(z.string().min(1)).min(1).optional(), }) .strict() .superRefine((data, ctx) => { @@ -498,6 +651,101 @@ export const AgentCoreGatewayTargetSchema = z }); } } + if (data.targetType === 'httpRuntime') { + if (!data.httpRuntime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'httpRuntime targets require an httpRuntime configuration (with a runtime reference).', + path: ['httpRuntime'], + }); + } + if (data.endpoint) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'httpRuntime targets should use httpRuntime.runtimeEndpoint instead of endpoint.', + path: ['endpoint'], + }); + } + if (data.compute) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'compute is not applicable for httpRuntime target type', + path: ['compute'], + }); + } + if (data.apiGateway) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'apiGateway is not applicable for httpRuntime target type', + path: ['apiGateway'], + }); + } + if (data.lambdaFunctionArn) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'lambdaFunctionArn is not applicable for httpRuntime target type', + path: ['lambdaFunctionArn'], + }); + } + if (data.toolDefinitions && data.toolDefinitions.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'toolDefinitions is not applicable for httpRuntime target type', + path: ['toolDefinitions'], + }); + } + } + if (data.targetType === 'passthrough') { + if (!data.passthrough) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'passthrough targets require a passthrough configuration (with an endpoint).', + path: ['passthrough'], + }); + } + if (data.endpoint) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'passthrough targets should use passthrough.endpoint instead of endpoint.', + path: ['endpoint'], + }); + } + if (data.compute) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'compute is not applicable for passthrough target type', + path: ['compute'], + }); + } + if (data.apiGateway) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'apiGateway is not applicable for passthrough target type', + path: ['apiGateway'], + }); + } + if (data.lambdaFunctionArn) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'lambdaFunctionArn is not applicable for passthrough target type', + path: ['lambdaFunctionArn'], + }); + } + if (data.httpRuntime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'httpRuntime is not applicable for passthrough target type', + path: ['httpRuntime'], + }); + } + if (data.toolDefinitions && data.toolDefinitions.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'toolDefinitions is not applicable for passthrough target type', + path: ['toolDefinitions'], + }); + } + } if (data.targetType === 'lambda' && !data.compute) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -512,6 +760,214 @@ export const AgentCoreGatewayTargetSchema = z path: ['toolDefinitions'], }); } + if (data.targetType === 'connector') { + if (!data.connectorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'connectorId is required for connector target type', + path: ['connectorId'], + }); + } + if (data.connectorId === CONNECTOR_ID.BEDROCK_KNOWLEDGE_BASES) { + if (!data.knowledgeBaseId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `knowledgeBaseId is required for connectorId '${data.connectorId}'`, + path: ['knowledgeBaseId'], + }); + } + if (data.knowledgeBaseIds) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `knowledgeBaseIds is not applicable for connectorId '${data.connectorId}' (use knowledgeBaseId)`, + path: ['knowledgeBaseIds'], + }); + } + } + if (data.connectorId === CONNECTOR_ID.BEDROCK_AGENTIC_RETRIEVE) { + if (!data.knowledgeBaseIds || data.knowledgeBaseIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `knowledgeBaseIds (non-empty) is required for connectorId '${data.connectorId}'`, + path: ['knowledgeBaseIds'], + }); + } + if (data.knowledgeBaseId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `knowledgeBaseId is not applicable for connectorId '${data.connectorId}' (use knowledgeBaseIds)`, + path: ['knowledgeBaseId'], + }); + } + } + if (data.excludeDomains) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `excludeDomains only applies to webSearch target type`, + path: ['excludeDomains'], + }); + } + if (data.compute) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'compute is not applicable for connector target type', + path: ['compute'], + }); + } + if (data.endpoint) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'endpoint is not applicable for connector target type', + path: ['endpoint'], + }); + } + if (data.toolDefinitions && data.toolDefinitions.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'toolDefinitions is not applicable for connector target type', + path: ['toolDefinitions'], + }); + } + if (data.apiGateway) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'apiGateway is not applicable for connector target type', + path: ['apiGateway'], + }); + } + if (data.lambdaFunctionArn) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'lambdaFunctionArn is not applicable for connector target type', + path: ['lambdaFunctionArn'], + }); + } + if (data.schemaSource) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'schemaSource is not applicable for connector target type', + path: ['schemaSource'], + }); + } + } + if (data.targetType === 'webSearch') { + // Web search is invoked via the gateway's IAM role and takes only an + // optional excludeDomains list. Reject anything else. + if (data.compute) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'compute is not applicable for webSearch target type', + path: ['compute'], + }); + } + if (data.endpoint) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'endpoint is not applicable for webSearch target type', + path: ['endpoint'], + }); + } + if (data.toolDefinitions && data.toolDefinitions.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'toolDefinitions is not applicable for webSearch target type', + path: ['toolDefinitions'], + }); + } + if (data.apiGateway) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'apiGateway is not applicable for webSearch target type', + path: ['apiGateway'], + }); + } + if (data.lambdaFunctionArn) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'lambdaFunctionArn is not applicable for webSearch target type', + path: ['lambdaFunctionArn'], + }); + } + if (data.schemaSource) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'schemaSource is not applicable for webSearch target type', + path: ['schemaSource'], + }); + } + if (data.httpRuntime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'httpRuntime is not applicable for webSearch target type', + path: ['httpRuntime'], + }); + } + if (data.passthrough) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'passthrough is not applicable for webSearch target type', + path: ['passthrough'], + }); + } + if (data.outboundAuth && data.outboundAuth.type !== 'NONE') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'outboundAuth is not applicable for webSearch target type (uses gateway IAM role)', + path: ['outboundAuth'], + }); + } + if (data.connectorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'connectorId is not applicable for webSearch target type', + path: ['connectorId'], + }); + } + if (data.knowledgeBaseId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'knowledgeBaseId is not applicable for webSearch target type', + path: ['knowledgeBaseId'], + }); + } + if (data.knowledgeBaseIds) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'knowledgeBaseIds is not applicable for webSearch target type', + path: ['knowledgeBaseIds'], + }); + } + } + if (data.targetType !== 'connector' && data.targetType !== 'webSearch') { + if (data.connectorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `connectorId only applies to connector target type`, + path: ['connectorId'], + }); + } + if (data.knowledgeBaseId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `knowledgeBaseId only applies to connector target type`, + path: ['knowledgeBaseId'], + }); + } + if (data.knowledgeBaseIds) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `knowledgeBaseIds only applies to connector target type`, + path: ['knowledgeBaseIds'], + }); + } + if (data.excludeDomains) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `excludeDomains only applies to webSearch target type`, + path: ['excludeDomains'], + }); + } + } // Centralized outbound auth validation (driven by TARGET_TYPE_AUTH_CONFIG) const authConfig = TARGET_TYPE_AUTH_CONFIG[data.targetType]; const authType = data.outboundAuth?.type ?? 'NONE'; @@ -536,13 +992,39 @@ export const AgentCoreGatewayTargetSchema = z path: ['outboundAuth'], }); } - if (data.outboundAuth && data.outboundAuth.type !== 'NONE' && !data.outboundAuth.credentialName) { + if ( + data.outboundAuth && + data.outboundAuth.type !== 'NONE' && + data.outboundAuth.type !== 'GATEWAY_IAM_ROLE' && + data.outboundAuth.type !== 'JWT_PASSTHROUGH' && + !data.outboundAuth.credentialName + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${data.outboundAuth.type} outbound auth requires a credentialName.`, path: ['outboundAuth', 'credentialName'], }); } + // GATEWAY_IAM_ROLE on passthrough requires service + if ( + data.targetType === 'passthrough' && + data.outboundAuth?.type === 'GATEWAY_IAM_ROLE' && + !data.outboundAuth.service + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'GATEWAY_IAM_ROLE outbound auth on passthrough targets requires a service name.', + path: ['outboundAuth', 'service'], + }); + } + // JWT_PASSTHROUGH is only valid for passthrough targets + if (data.outboundAuth?.type === 'JWT_PASSTHROUGH' && data.targetType !== 'passthrough') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'JWT_PASSTHROUGH outbound auth is only valid for passthrough targets.', + path: ['outboundAuth', 'type'], + }); + } }); export type AgentCoreGatewayTarget = z.infer; @@ -577,11 +1059,16 @@ export type GatewayPolicyEngineConfiguration = z.infer; + export const AgentCoreGatewaySchema = z .object({ name: GatewayNameSchema, /** Actual AWS resource name for imported gateways. When set, CDK uses this instead of generating projectName-name. */ resourceName: GatewayNameSchema.optional(), + /** Protocol type for this gateway. */ + protocolType: GatewayProtocolTypeSchema.optional(), description: z.string().optional(), targets: z.array(AgentCoreGatewayTargetSchema), /** Authorization type for the gateway. Defaults to 'NONE'. */ @@ -615,7 +1102,20 @@ export const AgentCoreGatewaySchema = z message: 'customJwtAuthorizer configuration is required when authorizerType is CUSTOM_JWT', path: ['authorizerConfiguration'], } - ); + ) + .superRefine((gw, ctx) => { + // A protocolType: "None" (HTTP) gateway is a superset: it can host any target + // type, including MCP targets (mcpServer, connector, KB, etc.). Only an MCP + // gateway is restrictive — it cannot host the HTTP-only target types. + for (const target of gw.targets) { + if (gw.protocolType !== 'None' && NON_MCP_TARGET_TYPES.includes(target.targetType)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Target "${target.name}" is ${target.targetType} but gateway does not have protocolType: "None". Add --protocol-type None when creating the gateway.`, + }); + } + } + }); export type AgentCoreGateway = z.infer; diff --git a/src/schema/schemas/primitives/__tests__/ab-test.test.ts b/src/schema/schemas/primitives/__tests__/ab-test.test.ts index 874cd7d13..3adc1132d 100644 --- a/src/schema/schemas/primitives/__tests__/ab-test.test.ts +++ b/src/schema/schemas/primitives/__tests__/ab-test.test.ts @@ -119,9 +119,7 @@ describe('ABTestSchema', () => { ...validABTest, description: 'A test', roleArn: 'arn:aws:iam::123:role/MyRole', - maxDurationDays: 30, enableOnCreate: true, - trafficAllocationConfig: { routeOnHeader: { headerName: 'X-AB-Route' } }, }); expect(result.success).toBe(true); }); @@ -142,11 +140,6 @@ describe('ABTestSchema', () => { expect(result.success).toBe(false); }); - it('rejects maxDurationDays outside 1-90', () => { - expect(ABTestSchema.safeParse({ ...validABTest, maxDurationDays: 0 }).success).toBe(false); - expect(ABTestSchema.safeParse({ ...validABTest, maxDurationDays: 91 }).success).toBe(false); - }); - describe('variant weight sum validation', () => { it('accepts weights summing to 100 (50/50)', () => { const test = { diff --git a/src/schema/schemas/primitives/__tests__/evaluator.test.ts b/src/schema/schemas/primitives/__tests__/evaluator.test.ts index f378e526b..379176244 100644 --- a/src/schema/schemas/primitives/__tests__/evaluator.test.ts +++ b/src/schema/schemas/primitives/__tests__/evaluator.test.ts @@ -5,6 +5,7 @@ import { EvaluatorNameSchema, NumericalRatingSchema, RatingScaleSchema, + isValidKmsKeyArn, } from '../evaluator'; import { describe, expect, it } from 'vitest'; @@ -155,3 +156,45 @@ describe('EvaluatorConfigSchema', () => { expect(EvaluatorConfigSchema.safeParse({}).success).toBe(false); }); }); + +describe('isValidKmsKeyArn', () => { + it('accepts valid commercial KMS key ARN', () => { + expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012')).toBe(true); + }); + + it('accepts valid GovCloud KMS key ARN', () => { + expect( + isValidKmsKeyArn('arn:aws-us-gov:kms:us-gov-west-1:123456789012:key/12345678-1234-1234-1234-123456789012') + ).toBe(true); + }); + + it('accepts valid China partition KMS key ARN', () => { + expect(isValidKmsKeyArn('arn:aws-cn:kms:cn-north-1:123456789012:key/12345678-1234-1234-1234-123456789012')).toBe( + true + ); + }); + + it('rejects ARN with wrong service', () => { + expect(isValidKmsKeyArn('arn:aws:s3:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012')).toBe(false); + }); + + it('rejects ARN with alias instead of key', () => { + expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:123456789012:alias/my-key')).toBe(false); + }); + + it('rejects ARN with invalid account ID length', () => { + expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:12345:key/12345678-1234-1234-1234-123456789012')).toBe(false); + }); + + it('rejects ARN with invalid key UUID format', () => { + expect(isValidKmsKeyArn('arn:aws:kms:us-east-1:123456789012:key/not-a-valid-uuid')).toBe(false); + }); + + it('rejects empty string', () => { + expect(isValidKmsKeyArn('')).toBe(false); + }); + + it('rejects random string', () => { + expect(isValidKmsKeyArn('not-an-arn-at-all')).toBe(false); + }); +}); diff --git a/src/schema/schemas/primitives/__tests__/harness.test.ts b/src/schema/schemas/primitives/__tests__/harness.test.ts index 4b63fd774..c4c89a390 100644 --- a/src/schema/schemas/primitives/__tests__/harness.test.ts +++ b/src/schema/schemas/primitives/__tests__/harness.test.ts @@ -2,6 +2,7 @@ import { HarnessModelProviderSchema, HarnessModelSchema, HarnessNameSchema, + HarnessSkillSchema, HarnessSpecSchema, HarnessToolSchema, HarnessToolTypeSchema, @@ -13,15 +14,15 @@ describe('HarnessNameSchema', () => { expect(HarnessNameSchema.safeParse(name).success).toBe(true); }); - it('accepts 48-character name (max)', () => { - const name = 'A' + 'b'.repeat(47); - expect(name).toHaveLength(48); + it('accepts 40-character name (max)', () => { + const name = 'A' + 'b'.repeat(39); + expect(name).toHaveLength(40); expect(HarnessNameSchema.safeParse(name).success).toBe(true); }); - it('rejects 49-character name', () => { - const name = 'A' + 'b'.repeat(48); - expect(name).toHaveLength(49); + it('rejects 41-character name', () => { + const name = 'A' + 'b'.repeat(40); + expect(name).toHaveLength(41); expect(HarnessNameSchema.safeParse(name).success).toBe(false); }); @@ -56,7 +57,7 @@ describe('HarnessToolTypeSchema', () => { }); describe('HarnessModelProviderSchema', () => { - it.each(['bedrock', 'open_ai', 'gemini'])('accepts "%s"', provider => { + it.each(['bedrock', 'open_ai', 'gemini', 'lite_llm'])('accepts "%s"', provider => { expect(HarnessModelProviderSchema.safeParse(provider).success).toBe(true); }); @@ -343,15 +344,68 @@ describe('HarnessModelSchema', () => { }); it('accepts gemini model with topK', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'gemini', + modelId: 'gemini-2.5-pro', + apiKeyArn: 'arn:aws:bedrock-agentcore:us-west-2:123:apikey/abc', + topK: 40, + }); + expect(result.success).toBe(true); + }); + + it('rejects non-integer topK', () => { const result = HarnessModelSchema.safeParse({ provider: 'gemini', modelId: 'gemini-2.5-pro', apiKeyArn: 'arn:aws:bedrock-agentcore:us-west-2:123:apikey/abc', topK: 0.5, }); + expect(result.success).toBe(false); + }); + + it('rejects topK above 500', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'gemini', + modelId: 'gemini-2.5-pro', + apiKeyArn: 'arn:aws:bedrock-agentcore:us-west-2:123:apikey/abc', + topK: 501, + }); + expect(result.success).toBe(false); + }); + + it('requires apiKeyArn for open_ai and gemini providers', () => { + expect(HarnessModelSchema.safeParse({ provider: 'open_ai', modelId: 'gpt-4o' }).success).toBe(false); + expect(HarnessModelSchema.safeParse({ provider: 'gemini', modelId: 'gemini-2.5-pro' }).success).toBe(false); + expect(HarnessModelSchema.safeParse({ provider: 'bedrock', modelId: 'claude' }).success).toBe(true); + }); + + it('accepts lite_llm model without apiKeyArn (key is optional)', () => { + const result = HarnessModelSchema.safeParse({ provider: 'lite_llm', modelId: 'anthropic/claude-sonnet-4-5' }); expect(result.success).toBe(true); }); + it('accepts lite_llm model with apiBase and additionalParams', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'lite_llm', + modelId: 'anthropic/claude-sonnet-4-5', + apiBase: 'https://proxy.example.com/v1', + additionalParams: { reasoning_effort: 'high' }, + }); + expect(result.success).toBe(true); + }); + + it('rejects apiBase for non-lite_llm providers', () => { + expect(HarnessModelSchema.safeParse({ provider: 'bedrock', modelId: 'm', apiBase: 'https://x' }).success).toBe( + false + ); + }); + + it('rejects additionalParams for non-lite_llm providers', () => { + expect( + HarnessModelSchema.safeParse({ provider: 'bedrock', modelId: 'm', additionalParams: { foo: 'bar' } }).success + ).toBe(false); + }); + it('rejects temperature above 2.0', () => { const result = HarnessModelSchema.safeParse({ provider: 'bedrock', @@ -397,7 +451,7 @@ describe('HarnessModelSchema', () => { const result = HarnessModelSchema.safeParse({ provider: 'bedrock', modelId: 'us.anthropic.claude-sonnet-4-5-20250514-v1:0', - topK: 0.5, + topK: 40, }); expect(result.success).toBe(false); if (!result.success) { @@ -436,10 +490,25 @@ describe('HarnessSpecSchema', () => { } }); - it('accepts harness with system prompt file path', () => { + it('accepts harness with a literal system prompt', () => { const result = HarnessSpecSchema.safeParse({ ...minimalHarness, - systemPrompt: './system-prompt.md', + systemPrompt: 'You are a helpful research assistant. Cite your sources.', + }); + expect(result.success).toBe(true); + }); + + it('rejects a file-path-shaped system prompt (migration fail-fast; use system-prompt.md)', () => { + for (const bad of ['./system-prompt.md', '../prompts/system.md', 'prompts/system.txt']) { + const result = HarnessSpecSchema.safeParse({ ...minimalHarness, systemPrompt: bad }); + expect(result.success).toBe(false); + } + }); + + it('does not misfire on prose that merely mentions a filename', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + systemPrompt: 'Refer to docs.md when the user asks about setup.', }); expect(result.success).toBe(true); }); @@ -474,14 +543,47 @@ describe('HarnessSpecSchema', () => { } }); - it('accepts harness with skills as string paths', () => { + it('accepts harness with path skills', () => { const result = HarnessSpecSchema.safeParse({ ...minimalHarness, - skills: ['./skills/research', '.agents/skills/xlsx'], + skills: [{ path: './skills/research' }, { path: '.agents/skills/xlsx' }], }); expect(result.success).toBe(true); }); + it('accepts harness with s3 skills', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + skills: [{ s3Uri: 's3://my-bucket/skills/calc' }], + }); + expect(result.success).toBe(true); + }); + + it('accepts harness with git skills (public and private)', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + skills: [ + { gitUrl: 'https://github.com/owner/repo', path: 'skills/greet' }, + { + gitUrl: 'https://github.com/owner/private', + auth: { + credentialName: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/my-pat', + }, + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('rejects git skill with non-https URL', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + skills: [{ gitUrl: 'git@github.com:owner/repo' }], + }); + expect(result.success).toBe(false); + }); + it('accepts harness with allowedTools', () => { const result = HarnessSpecSchema.safeParse({ ...minimalHarness, @@ -554,6 +656,252 @@ describe('HarnessSpecSchema', () => { expect(result.success).toBe(false); }); + // B5 — truncation strategy "none" + it('accepts truncation strategy "none"', () => { + const result = HarnessSpecSchema.safeParse({ ...minimalHarness, truncation: { strategy: 'none' } }); + expect(result.success).toBe(true); + }); + + // B27e — truncation config must match the strategy + it('rejects a summarization config under a sliding_window strategy', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + truncation: { strategy: 'sliding_window', config: { summarization: { summaryRatio: 0.5 } } }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a config under the "none" strategy', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + truncation: { strategy: 'none', config: { slidingWindow: { messagesCount: 5 } } }, + }); + expect(result.success).toBe(false); + }); + + // B6 / B7 — memory messagesCount + retrievalConfig + it('accepts memory messagesCount and retrievalConfig', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { name: 'mem', messagesCount: 20, retrievalConfig: { topK: 5, relevanceScore: 0.7 } }, + }); + expect(result.success).toBe(true); + }); + + it('rejects an unknown key in memory.retrievalConfig', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { name: 'mem', retrievalConfig: { topK: 5, strategyId: 'x' } }, + }); + expect(result.success).toBe(false); + }); + + // Review fix — retrievalConfig must carry at least one knob (an empty {} fans out to per-namespace + // {} objects, the pre-v6 crash shape). + it('rejects an empty memory.retrievalConfig', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { name: 'mem', retrievalConfig: {} }, + }); + expect(result.success).toBe(false); + }); + + // Review fix — HarnessMemoryRefSchema is .strict(): a typo'd key (e.g. messageCount) is a parse + // error, not a silently-dropped field. + it('rejects an unknown key on the memory ref (typo guard)', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { name: 'mem', messageCount: 20 }, + }); + expect(result.success).toBe(false); + }); + + // Review fix — retrievalConfig is dropped at synth for a by-arn ref (no resolvable strategies), so + // reject it at parse time. Gated on `arn` alone: the { arn, name, retrievalConfig } combo must also + // fail, because arn takes precedence in resolveHarnessMemory. + it('rejects retrievalConfig on a by-arn memory ref', () => { + const byArn = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { arn: 'arn:aws:bedrock-agentcore:us-west-2:123:memory/abc', retrievalConfig: { topK: 5 } }, + }); + expect(byArn.success).toBe(false); + + const byArnAndName = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { arn: 'arn:aws:bedrock-agentcore:us-west-2:123:memory/abc', name: 'mem', retrievalConfig: { topK: 5 } }, + }); + expect(byArnAndName.success).toBe(false); + }); + + it('still accepts messagesCount/actorId on a by-arn memory ref', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { arn: 'arn:aws:bedrock-agentcore:us-west-2:123:memory/abc', actorId: 'user-1', messagesCount: 20 }, + }); + expect(result.success).toBe(true); + }); + + // Managed memory + 3-mode discriminated union (NY-Summit). + describe('HarnessMemoryRefSchema — 3-mode union', () => { + it('accepts managed mode with default-shaped strategies', () => { + const r = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { mode: 'managed', strategies: ['SEMANTIC', 'SUMMARIZATION'] }, + }); + expect(r.success).toBe(true); + }); + + it('leaves managed strategies absent when omitted (service applies its own default)', () => { + const r = HarnessSpecSchema.safeParse({ ...minimalHarness, memory: { mode: 'managed' } }); + expect(r.success).toBe(true); + if (r.success && r.data.memory?.mode === 'managed') { + expect(r.data.memory.strategies).toBeUndefined(); + } + }); + + it('rejects CUSTOM in managed strategies (not valid for managed memory)', () => { + const r = HarnessSpecSchema.safeParse({ ...minimalHarness, memory: { mode: 'managed', strategies: ['CUSTOM'] } }); + expect(r.success).toBe(false); + }); + + it('rejects an out-of-range managed eventExpiryDuration', () => { + const r = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { mode: 'managed', strategies: ['SEMANTIC'], eventExpiryDuration: 2 }, + }); + expect(r.success).toBe(false); + }); + + it('accepts managed eventExpiryDuration within 3-365', () => { + const r = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { mode: 'managed', strategies: ['SEMANTIC'], eventExpiryDuration: 30 }, + }); + expect(r.success).toBe(true); + }); + + it('rejects an unknown key on the managed arm (strict)', () => { + const r = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { mode: 'managed', strategies: ['SEMANTIC'], bogus: true }, + }); + expect(r.success).toBe(false); + }); + + it('accepts existing mode by name', () => { + expect( + HarnessSpecSchema.safeParse({ ...minimalHarness, memory: { mode: 'existing', name: 'mem' } }).success + ).toBe(true); + }); + + it('rejects existing mode with neither arn nor name', () => { + expect(HarnessSpecSchema.safeParse({ ...minimalHarness, memory: { mode: 'existing' } }).success).toBe(false); + }); + + it('preserves the by-arn retrievalConfig rejection on the existing arm', () => { + const r = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { + mode: 'existing', + arn: 'arn:aws:bedrock-agentcore:us-west-2:1:memory/m-aBcD012345', + retrievalConfig: { topK: 5 }, + }, + }); + expect(r.success).toBe(false); + }); + + it('accepts disabled mode', () => { + expect(HarnessSpecSchema.safeParse({ ...minimalHarness, memory: { mode: 'disabled' } }).success).toBe(true); + }); + + it('rejects an unknown key on the disabled arm (strict)', () => { + expect( + HarnessSpecSchema.safeParse({ ...minimalHarness, memory: { mode: 'disabled', bogus: true } }).success + ).toBe(false); + }); + + it('rejects an unknown mode', () => { + expect(HarnessSpecSchema.safeParse({ ...minimalHarness, memory: { mode: 'bogus' } }).success).toBe(false); + }); + + describe('legacy normalization', () => { + it('maps a legacy by-name ref to existing', () => { + const r = HarnessSpecSchema.safeParse({ ...minimalHarness, memory: { name: 'mem' } }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.memory).toEqual({ mode: 'existing', name: 'mem' }); + }); + + it('maps a legacy by-arn ref to existing', () => { + const r = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { arn: 'arn:aws:bedrock-agentcore:us-west-2:1:memory/m-aBcD012345' }, + }); + expect(r.success).toBe(true); + if (r.success && r.data.memory) expect(r.data.memory.mode).toBe('existing'); + }); + + it('leaves absent memory absent (never invents managed)', () => { + const r = HarnessSpecSchema.safeParse({ ...minimalHarness }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.memory).toBeUndefined(); + }); + + it('passes an already-tagged managed ref through unchanged', () => { + const r = HarnessSpecSchema.safeParse({ + ...minimalHarness, + memory: { mode: 'managed', strategies: ['SEMANTIC', 'SUMMARIZATION'] }, + }); + expect(r.success).toBe(true); + if (r.success && r.data.memory) expect(r.data.memory.mode).toBe('managed'); + }); + }); + }); + + // Review fix — both truncation arms present must fail (the outer .strict() rejects the second + // arm's key rather than silently dropping it). + it('rejects a truncation config carrying both arms', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + truncation: { + strategy: 'sliding_window', + config: { slidingWindow: { messagesCount: 5 }, summarization: { summaryRatio: 0.5 } }, + }, + }); + expect(result.success).toBe(false); + }); + + // B8 — sessionStoragePath CFN MountPath parity ('/mnt/' is 5 chars + no subdir; spaces and + // multi-level paths fail the pattern; a valid single-level path is accepted). + it('enforces the CFN MountPath constraint on sessionStoragePath', () => { + for (const p of ['/mnt/', '/mnt/bad path', '/mnt/x/y/z']) { + expect(HarnessSpecSchema.safeParse({ ...minimalHarness, sessionStoragePath: p }).success).toBe(false); + } + expect(HarnessSpecSchema.safeParse({ ...minimalHarness, sessionStoragePath: '/mnt/data' }).success).toBe(true); + }); + + // B16 — empty / whitespace system prompt + it('rejects an empty or whitespace-only system prompt', () => { + expect(HarnessSpecSchema.safeParse({ ...minimalHarness, systemPrompt: '' }).success).toBe(false); + expect(HarnessSpecSchema.safeParse({ ...minimalHarness, systemPrompt: ' ' }).success).toBe(false); + }); + + // B21 — env-var value length + map size + it('rejects an env-var value over 5000 chars or more than 50 entries', () => { + expect( + HarnessSpecSchema.safeParse({ ...minimalHarness, environmentVariables: { K: 'x'.repeat(5001) } }).success + ).toBe(false); + const many: Record = {}; + for (let i = 0; i < 51; i++) many[`K${i}`] = 'v'; + expect(HarnessSpecSchema.safeParse({ ...minimalHarness, environmentVariables: many }).success).toBe(false); + }); + + // B25 — containerUri ECR pattern + it('rejects a non-ECR containerUri', () => { + expect( + HarnessSpecSchema.safeParse({ ...minimalHarness, containerUri: 'docker.io/library/nginx:latest' }).success + ).toBe(false); + }); + it('accepts harness with container config', () => { const result = HarnessSpecSchema.safeParse({ ...minimalHarness, @@ -657,7 +1005,7 @@ describe('HarnessSpecSchema', () => { temperature: 0.7, maxTokens: 4096, }, - systemPrompt: './system-prompt.md', + systemPrompt: 'You are a research agent. Use tools when appropriate and cite sources.', tools: [ { type: 'agentcore_browser', name: 'browser' }, { type: 'agentcore_code_interpreter', name: 'code_interpreter' }, @@ -678,7 +1026,7 @@ describe('HarnessSpecSchema', () => { }, }, ], - skills: ['./skills/research'], + skills: [{ path: './skills/research' }], allowedTools: ['*'], memory: { name: 'research_memory' }, maxIterations: 75, @@ -782,3 +1130,129 @@ describe('HarnessSpecSchema', () => { expect(result.success).toBe(false); }); }); + +describe('HarnessSkillSchema', () => { + it('accepts a bare path string and normalizes to object', () => { + const result = HarnessSkillSchema.safeParse('./my-skill'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ path: './my-skill' }); + } + }); + + it('accepts a path object', () => { + const result = HarnessSkillSchema.safeParse({ path: './skills/research' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ path: './skills/research' }); + } + }); + + it('accepts an S3 source', () => { + const result = HarnessSkillSchema.safeParse({ s3Uri: 's3://my-bucket/skills/research' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ s3Uri: 's3://my-bucket/skills/research' }); + } + }); + + it('rejects S3 source without s3:// prefix', () => { + const result = HarnessSkillSchema.safeParse({ s3Uri: 'my-bucket/skills/research' }); + expect(result.success).toBe(false); + }); + + it('accepts a git source with URL only', () => { + const result = HarnessSkillSchema.safeParse({ gitUrl: 'https://github.com/org/repo' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ gitUrl: 'https://github.com/org/repo' }); + } + }); + + it('accepts a git source with path and auth', () => { + const input = { + gitUrl: 'https://github.com/org/repo', + path: 'skills/research', + auth: { + credentialName: 'my-cred', + username: 'bot-user', + }, + }; + const result = HarnessSkillSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(input); + } + }); + + it('rejects git source without https:// prefix', () => { + const result = HarnessSkillSchema.safeParse({ gitUrl: 'git@github.com:org/repo.git' }); + expect(result.success).toBe(false); + }); + + it('rejects empty string', () => { + const result = HarnessSkillSchema.safeParse(''); + expect(result.success).toBe(false); + }); + + it('rejects empty path object', () => { + const result = HarnessSkillSchema.safeParse({ path: '' }); + expect(result.success).toBe(false); + }); + + it('accepts an AWS skills source without paths', () => { + const result = HarnessSkillSchema.safeParse({ awsSkills: {} }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ awsSkills: {} }); + } + }); + + it('accepts an AWS skills source with paths', () => { + const result = HarnessSkillSchema.safeParse({ awsSkills: { paths: ['core-skills/*'] } }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ awsSkills: { paths: ['core-skills/*'] } }); + } + }); + + it('rejects an AWS skills source with empty path string', () => { + const result = HarnessSkillSchema.safeParse({ awsSkills: { paths: [''] } }); + expect(result.success).toBe(false); + }); +}); + +describe('HarnessSpecSchema skills field', () => { + const minimalHarness = { + name: 'TestHarness', + model: { provider: 'bedrock', modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0' }, + }; + + it('accepts mixed skill sources including AWS skills', () => { + const result = HarnessSpecSchema.safeParse({ + ...minimalHarness, + skills: [ + './local-skill', + { s3Uri: 's3://bucket/skill' }, + { gitUrl: 'https://github.com/org/repo', path: 'skills/foo' }, + { awsSkills: { paths: ['core-skills/*'] } }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.skills).toHaveLength(4); + expect(result.data.skills[0]).toEqual({ path: './local-skill' }); + expect(result.data.skills[1]).toEqual({ s3Uri: 's3://bucket/skill' }); + expect(result.data.skills[2]).toEqual({ gitUrl: 'https://github.com/org/repo', path: 'skills/foo' }); + expect(result.data.skills[3]).toEqual({ awsSkills: { paths: ['core-skills/*'] } }); + } + }); + + it('defaults skills to empty array', () => { + const result = HarnessSpecSchema.safeParse(minimalHarness); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.skills).toEqual([]); + } + }); +}); diff --git a/src/schema/schemas/primitives/__tests__/http-gateway.test.ts b/src/schema/schemas/primitives/__tests__/http-gateway.test.ts deleted file mode 100644 index 4fd885df1..000000000 --- a/src/schema/schemas/primitives/__tests__/http-gateway.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { HttpGatewayNameSchema, HttpGatewaySchema } from '../http-gateway'; -import { describe, expect, it } from 'vitest'; - -describe('HttpGatewayNameSchema', () => { - it('accepts valid name starting with letter', () => { - expect(HttpGatewayNameSchema.safeParse('MyGateway1').success).toBe(true); - }); - - it('accepts name with hyphens', () => { - expect(HttpGatewayNameSchema.safeParse('my-gateway').success).toBe(true); - }); - - it('rejects empty string', () => { - expect(HttpGatewayNameSchema.safeParse('').success).toBe(false); - }); - - it('rejects name starting with number', () => { - expect(HttpGatewayNameSchema.safeParse('1gateway').success).toBe(false); - }); - - it('rejects name with underscores', () => { - expect(HttpGatewayNameSchema.safeParse('my_gateway').success).toBe(false); - }); - - it('accepts name longer than 24 chars', () => { - expect(HttpGatewayNameSchema.safeParse('a'.repeat(25)).success).toBe(true); - }); - - it('accepts name at 47 chars (room for 1-char project name + hyphen)', () => { - expect(HttpGatewayNameSchema.safeParse('a' + 'b'.repeat(46)).success).toBe(true); - }); -}); - -describe('HttpGatewaySchema', () => { - const validHttpGateway = { - name: 'MyGateway', - runtimeRef: 'my-runtime', - }; - - it('accepts valid HTTP gateway with required fields', () => { - expect(HttpGatewaySchema.safeParse(validHttpGateway).success).toBe(true); - }); - - it('accepts valid HTTP gateway with all optional fields', () => { - const result = HttpGatewaySchema.safeParse({ - ...validHttpGateway, - description: 'A test gateway', - roleArn: 'arn:aws:iam::123456789012:role/MyRole', - }); - expect(result.success).toBe(true); - }); - - it('rejects missing name', () => { - const { name: _, ...withoutName } = validHttpGateway; - expect(HttpGatewaySchema.safeParse(withoutName).success).toBe(false); - }); - - it('rejects missing runtimeRef', () => { - const { runtimeRef: _, ...withoutRuntimeRef } = validHttpGateway; - expect(HttpGatewaySchema.safeParse(withoutRuntimeRef).success).toBe(false); - }); - - it('accepts name longer than 24 chars (no standalone max cap)', () => { - expect(HttpGatewaySchema.safeParse({ ...validHttpGateway, name: 'a' + 'b'.repeat(30) }).success).toBe(true); - }); - - it('rejects name starting with number', () => { - expect(HttpGatewaySchema.safeParse({ ...validHttpGateway, name: '1Gateway' }).success).toBe(false); - }); - - it('rejects name with invalid characters (underscores)', () => { - expect(HttpGatewaySchema.safeParse({ ...validHttpGateway, name: 'my_gateway' }).success).toBe(false); - }); - - it('rejects extra unknown fields (.strict())', () => { - const result = HttpGatewaySchema.safeParse({ - ...validHttpGateway, - unknownField: 'should fail', - }); - expect(result.success).toBe(false); - }); -}); diff --git a/src/schema/schemas/primitives/__tests__/knowledge-base.test.ts b/src/schema/schemas/primitives/__tests__/knowledge-base.test.ts new file mode 100644 index 000000000..d752a2398 --- /dev/null +++ b/src/schema/schemas/primitives/__tests__/knowledge-base.test.ts @@ -0,0 +1,203 @@ +import { DataSourceSchema, KnowledgeBaseNameSchema, KnowledgeBaseSchema, S3DataSourceSchema } from '../knowledge-base'; +import { describe, expect, it } from 'vitest'; + +describe('KnowledgeBaseNameSchema', () => { + it('accepts a valid name', () => { + expect(() => KnowledgeBaseNameSchema.parse('product-docs')).not.toThrow(); + }); + + it('rejects names longer than 48 chars', () => { + expect(() => KnowledgeBaseNameSchema.parse('a'.repeat(49))).toThrow(); + }); + + it('rejects names that do not start with a letter', () => { + expect(() => KnowledgeBaseNameSchema.parse('1bad')).toThrow(); + }); +}); + +describe('S3DataSourceSchema', () => { + it('accepts a valid S3 URI with prefix', () => { + expect(() => S3DataSourceSchema.parse({ type: 'S3', uri: 's3://my-bucket/docs/' })).not.toThrow(); + }); + + it('accepts a valid S3 URI without trailing slash', () => { + expect(() => S3DataSourceSchema.parse({ type: 'S3', uri: 's3://my-bucket' })).not.toThrow(); + }); + + it('rejects a non-s3 URI', () => { + expect(() => S3DataSourceSchema.parse({ type: 'S3', uri: 'https://example.com' })).toThrow(); + }); + + it('rejects type other than S3', () => { + expect(() => S3DataSourceSchema.parse({ type: 'CONFLUENCE', uri: 's3://my-bucket/y/' })).toThrow(); + }); + + it('rejects unknown keys', () => { + expect(() => S3DataSourceSchema.parse({ type: 'S3', uri: 's3://my-bucket/', extra: 1 })).toThrow(); + }); + + it('rejects bucket with uppercase letter', () => { + expect(() => S3DataSourceSchema.parse({ type: 'S3', uri: 's3://My-Bucket/x' })).toThrow(); + }); + + it('rejects bucket with consecutive dots', () => { + expect(() => S3DataSourceSchema.parse({ type: 'S3', uri: 's3://my..bucket/x' })).toThrow(); + }); + + it('rejects bucket with trailing dot', () => { + expect(() => S3DataSourceSchema.parse({ type: 'S3', uri: 's3://my-bucket./x' })).toThrow(); + }); + + it('rejects xn-- reserved bucket prefix', () => { + expect(() => S3DataSourceSchema.parse({ type: 'S3', uri: 's3://xn--my-bucket/x' })).toThrow(); + }); + + it('rejects sthree- reserved bucket prefix', () => { + expect(() => S3DataSourceSchema.parse({ type: 'S3', uri: 's3://sthree-my-bucket/x' })).toThrow(); + }); + + it('rejects -s3alias reserved suffix', () => { + expect(() => S3DataSourceSchema.parse({ type: 'S3', uri: 's3://my-bucket-s3alias/x' })).toThrow(); + }); +}); + +describe('KnowledgeBaseSchema', () => { + it('accepts a minimal project-owned KB entry', () => { + expect(() => + KnowledgeBaseSchema.parse({ + name: 'product-docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/docs/' }], + }) + ).not.toThrow(); + }); + + it('accepts multiple data sources', () => { + expect(() => + KnowledgeBaseSchema.parse({ + name: 'company-docs', + dataSources: [ + { type: 'S3', uri: 's3://bucket/a/' }, + { type: 'S3', uri: 's3://bucket/b/' }, + ], + }) + ).not.toThrow(); + }); + + it('rejects entries with no data sources', () => { + expect(() => KnowledgeBaseSchema.parse({ name: 'empty', dataSources: [] })).toThrow(); + }); + + it('rejects duplicate data source URIs', () => { + expect(() => + KnowledgeBaseSchema.parse({ + name: 'dup', + dataSources: [ + { type: 'S3', uri: 's3://my-bucket/a/' }, + { type: 'S3', uri: 's3://my-bucket/a/' }, + ], + }) + ).toThrow(); + }); + + it('accepts optional description and gateway', () => { + expect(() => + KnowledgeBaseSchema.parse({ + name: 'docs', + description: 'Customer docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/d/' }], + gateway: 'main-gw', + }) + ).not.toThrow(); + }); + + it('rejects unknown top-level keys', () => { + expect(() => + KnowledgeBaseSchema.parse({ + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/' }], + foo: 'bar', + }) + ).toThrow(); + }); + + it('rejects description longer than 2048 chars', () => { + expect(() => + KnowledgeBaseSchema.parse({ + name: 'docs', + description: 'x'.repeat(2049), + dataSources: [{ type: 'S3', uri: 's3://my-bucket/' }], + }) + ).toThrow(); + }); + + it('defaults type to AgentCoreKnowledgeBase when omitted', () => { + const parsed = KnowledgeBaseSchema.parse({ + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/' }], + }); + expect(parsed.type).toBe('AgentCoreKnowledgeBase'); + }); + + it('rejects empty name', () => { + expect(() => + KnowledgeBaseSchema.parse({ + name: '', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/' }], + }) + ).toThrow(); + }); + + it('rejects empty gateway string', () => { + expect(() => + KnowledgeBaseSchema.parse({ + name: 'docs', + dataSources: [{ type: 'S3', uri: 's3://my-bucket/' }], + gateway: '', + }) + ).toThrow(); + }); +}); + +describe('DataSourceSchema — connector variants', () => { + it('accepts an S3 data source', () => { + const r = DataSourceSchema.safeParse({ type: 'S3', uri: 's3://my-bucket/docs/' }); + expect(r.success).toBe(true); + }); + + it('accepts a WEB connector-file data source', () => { + const r = DataSourceSchema.safeParse({ type: 'WEB', connectorConfigFile: 'app/web-docs/web.json' }); + expect(r.success).toBe(true); + }); + + it.each(['CONFLUENCE', 'SHAREPOINT', 'ONEDRIVE', 'GOOGLEDRIVE'])('accepts a %s connector-file data source', type => { + const r = DataSourceSchema.safeParse({ type, connectorConfigFile: `app/kb/${type}.json` }); + expect(r.success).toBe(true); + }); + + it('rejects a connector-file source missing connectorConfigFile', () => { + const r = DataSourceSchema.safeParse({ type: 'WEB' }); + expect(r.success).toBe(false); + }); + + it('rejects an unknown connector type', () => { + const r = DataSourceSchema.safeParse({ type: 'WEBCRAWLER', connectorConfigFile: 'x.json' }); + expect(r.success).toBe(false); + }); + + it('rejects a connector-file source that also carries a uri (strict)', () => { + const r = DataSourceSchema.safeParse({ type: 'WEB', connectorConfigFile: 'x.json', uri: 's3://b/' }); + expect(r.success).toBe(false); + }); + + it('dedups a mixed dataSources[] by uri AND connectorConfigFile', () => { + const r = KnowledgeBaseSchema.safeParse({ + name: 'kb', + dataSources: [ + { type: 'S3', uri: 's3://b/a/' }, + { type: 'WEB', connectorConfigFile: 'app/kb/web.json' }, + { type: 'WEB', connectorConfigFile: 'app/kb/web.json' }, + ], + }); + expect(r.success).toBe(false); + }); +}); diff --git a/src/schema/schemas/primitives/ab-test.ts b/src/schema/schemas/primitives/ab-test.ts index ec04ab4f7..431bdf7b9 100644 --- a/src/schema/schemas/primitives/ab-test.ts +++ b/src/schema/schemas/primitives/ab-test.ts @@ -115,8 +115,6 @@ export const ABTestSchema = z variants: z.array(ABTestVariantSchema).length(2), evaluationConfig: ABTestEvaluationConfigSchema, gatewayFilter: GatewayFilterSchema.optional(), - trafficAllocationConfig: TrafficAllocationConfigSchema.optional(), - maxDurationDays: z.number().int().min(1).max(90).optional(), enableOnCreate: z.boolean().optional(), promoted: z.boolean().optional(), }) diff --git a/src/schema/schemas/primitives/harness.ts b/src/schema/schemas/primitives/harness.ts index 4531774c2..c165e933e 100644 --- a/src/schema/schemas/primitives/harness.ts +++ b/src/schema/schemas/primitives/harness.ts @@ -4,12 +4,29 @@ import { LifecycleConfigurationSchema, NetworkConfigSchema, S3FilesAccessPointConfigSchema, + SessionStorageSchema, } from '../agent-env'; import { AuthorizerConfigSchema, RuntimeAuthorizerTypeSchema } from '../auth'; import { uniqueBy } from '../zod-util'; import { TagsSchema } from './tags'; import { z } from 'zod'; +/** + * ECR container image URI pattern, mirroring the CFN ContainerConfiguration.ContainerUri + * constraint (private 12-digit ECR registry or public.ecr.aws, optional tag/digest). + * A non-ECR URI is rejected client-side instead of failing at CloudFormation CREATE. + */ +export const CONTAINER_URI_PATTERN = + // eslint-disable-next-line security/detect-unsafe-regex -- mirrors the CFN ContainerUri pattern; input is length-bounded by .max(MAX_CONTAINER_URI_LENGTH) + /^(([0-9]{12})\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com(\.cn)?|public\.ecr\.aws)\/((?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)*[a-z0-9]+(?:[._-][a-z0-9]+)*)(?::([^:@]{1,300}))?(?:@(.+))?$/; +/** CFN ContainerConfiguration.ContainerUri maxLength. */ +export const MAX_CONTAINER_URI_LENGTH = 1024; +/** CFN EnvironmentVariables value maxLength and map maxProperties. */ +export const MAX_ENV_VAR_VALUE_LENGTH = 5000; +export const MAX_ENV_VARS = 50; +/** CFN/Smithy EnvironmentVariableKey length bounds (Smithy EnvironmentVariableKey: 1–100, no char pattern). */ +export const MAX_ENV_VAR_KEY_LENGTH = 100; + // ============================================================================ // Harness Name // ============================================================================ @@ -17,19 +34,22 @@ import { z } from 'zod'; export const HarnessNameSchema = z .string() .min(1, 'Harness name is required') - .max(48) + .max(40) .regex( - /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, - 'Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)' + /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/, + 'Must begin with a letter and contain only alphanumeric characters and underscores (max 40 chars)' ); // ============================================================================ // Model Configuration // ============================================================================ -export const HarnessModelProviderSchema = z.enum(['bedrock', 'open_ai', 'gemini']); +export const HarnessModelProviderSchema = z.enum(['bedrock', 'open_ai', 'gemini', 'lite_llm']); export type HarnessModelProvider = z.infer; +/** Max length of the LiteLLM apiBase URL (Smithy: HarnessLiteLlmApiBase, 1–16383). */ +export const MAX_LITE_LLM_API_BASE_LENGTH = 16383; + export const BedrockApiFormatSchema = z.enum(['converse_stream', 'responses', 'chat_completions']); export type BedrockApiFormat = z.infer; @@ -47,8 +67,12 @@ export const HarnessModelSchema = z apiFormat: HarnessApiFormatSchema.optional(), temperature: z.number().min(0).max(2).optional(), topP: z.number().min(0).max(1).optional(), - topK: z.number().min(0).max(1).optional(), + topK: z.number().int().min(0).max(500).optional(), maxTokens: z.number().int().min(1).optional(), + /** LiteLLM only: base URL for the third-party model provider's API endpoint. */ + apiBase: z.string().min(1).max(MAX_LITE_LLM_API_BASE_LENGTH).optional(), + /** LiteLLM only: provider-specific parameters passed through to the model provider unchanged. */ + additionalParams: z.record(z.string(), z.unknown()).optional(), }) .superRefine((model, ctx) => { if (model.topK !== undefined && model.provider !== 'gemini') { @@ -73,6 +97,28 @@ export const HarnessModelSchema = z }); } } + // CFN requires ApiKeyArn for the open_ai and gemini model configs (bedrock and lite_llm do not). + if (model.apiKeyArn === undefined && (model.provider === 'open_ai' || model.provider === 'gemini')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `apiKeyArn is required for the "${model.provider}" provider`, + path: ['apiKeyArn'], + }); + } + if (model.apiBase !== undefined && model.provider !== 'lite_llm') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'apiBase is only supported for the "lite_llm" provider', + path: ['apiBase'], + }); + } + if (model.additionalParams !== undefined && model.provider !== 'lite_llm') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'additionalParams is only supported for the "lite_llm" provider', + path: ['additionalParams'], + }); + } }); export type HarnessModel = z.infer; @@ -238,41 +284,239 @@ export type HarnessTool = z.infer; // Memory Reference // ============================================================================ -export const HarnessMemoryRefSchema = z.object({ - name: z.string().min(1).optional(), - arn: z.string().min(1).optional(), - actorId: z.string().optional(), -}); +/** + * Per-namespace retrieval tuning, applied as a flat default across every namespace + * of the referenced memory's strategies. Namespaces are only known at CDK synth + * (from the resolved Memory's strategies), so a per-namespace map cannot be authored + * here; the CDK fans this single config out to each namespace. StrategyId is omitted + * deliberately — the namespace already identifies the strategy and the id is + * service-assigned. Maps to CFN HarnessAgentCoreMemoryRetrievalConfig {TopK, RelevanceScore}. + */ +export const HarnessMemoryRetrievalConfigSchema = z + .object({ + topK: z.number().int().min(1).optional(), + relevanceScore: z.number().min(0).max(1).optional(), + }) + .strict() + // An empty `{}` parses against the optional fields, but fans out to per-namespace `{}` objects — + // the exact shape that crashed the pre-v6 service handler. Require at least one knob. + .refine(v => v.topK !== undefined || v.relevanceScore !== undefined, { + message: 'retrievalConfig must specify at least one of topK or relevanceScore', + }); + +export type HarnessMemoryRetrievalConfig = z.infer; + +/** + * Managed-memory strategy set. NOT MemoryStrategyTypeSchema: managed harness memory excludes + * CUSTOM (the CFN ManagedMemoryConfiguration.Strategies enum is exactly these four). + */ +export const ManagedMemoryStrategySchema = z.enum(['SEMANTIC', 'SUMMARIZATION', 'USER_PREFERENCE', 'EPISODIC']); + +/** + * Managed: the harness creates and owns its memory internally. The memory `arn` is read-only + * (service-populated) and never authored here. `strategies` is OPTIONAL and left absent by default: + * when omitted, the harness/service applies its own default strategy set. It is only written when the + * user explicitly tunes it, so the CLI never pins a default the service might evolve. + */ +const ManagedMemoryRefSchema = z + .object({ + mode: z.literal('managed'), + strategies: z.array(ManagedMemoryStrategySchema).min(1).max(4).optional(), + eventExpiryDuration: z.number().int().min(3).max(365).optional(), + encryptionKeyArn: z.string().min(1).optional(), + }) + .strict(); + +/** + * Existing (bring-your-own): reference a memory by name (project sibling) or arn, carrying the + * optional deploy-time tuning. This is the pre-union flat shape, now tagged `existing`. + */ +const ExistingMemoryRefSchema = z + .object({ + mode: z.literal('existing'), + name: z.string().min(1).optional(), + arn: z.string().min(1).optional(), + actorId: z.string().optional(), + /** Limits how many recent memory messages are loaded into context (CFN MessagesCount). */ + messagesCount: z.number().int().min(1).optional(), + /** Retrieval tuning applied to every namespace of the referenced memory (CFN RetrievalConfig). */ + retrievalConfig: HarnessMemoryRetrievalConfigSchema.optional(), + }) + // .strict() so a typo (e.g. `messageCount` for `messagesCount`) is a parse error rather than a + // silently-dropped field that defeats the B6 MessagesCount fix. + .strict() + .refine(m => m.arn != null || m.name != null, { + message: 'existing memory requires `arn` or `name`', + path: ['name'], + }) + .superRefine((ref, ctx) => { + // retrievalConfig is applied per-namespace, and namespaces are only resolvable from a by-name + // memory's strategies. At synth `arn` takes precedence over `name` (resolveHarnessMemory), so + // ANY ref carrying `arn` resolves no strategies and buildRetrievalConfig drops the tuning — + // including the `{ arn, name, retrievalConfig }` combo. Gate on `arn` alone, not `arn && !name`, + // so that combo can't slip through. (messagesCount and actorId are emitted directly from the + // ref, so they stay valid for a by-arn reference.) + if (ref.arn && ref.retrievalConfig !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'retrievalConfig is not supported when memory is referenced by `arn` (per-namespace tuning is only resolvable for a by-name reference). Reference the memory by `name` only, or drop retrievalConfig.', + path: ['retrievalConfig'], + }); + } + }); + +/** Disabled: explicit opt-out of memory. */ +const DisabledMemoryRefSchema = z.object({ mode: z.literal('disabled') }).strict(); + +/** + * Memory reference for a harness — exactly one of managed | existing | disabled. + * A legacy-aware preprocess maps the pre-union flat shape ({ name | arn | ... }) to the `existing` + * arm. Absent memory STAYS absent — managed is the default only for NEW harnesses (seeded at create + * time by the CLI/TUI), never invented here, so already-deployed harnesses are not auto-upgraded. + */ +export const HarnessMemoryRefSchema = z.preprocess( + val => { + if (val == null || typeof val !== 'object') return val; + const obj = val as Record; + if ('mode' in obj) return obj; // already union-tagged + // Legacy flat shape → existing (it always referenced a sibling/external memory by name or arn). + return { mode: 'existing', ...obj }; + }, + z.discriminatedUnion('mode', [ManagedMemoryRefSchema, ExistingMemoryRefSchema, DisabledMemoryRefSchema]) +); export type HarnessMemoryRef = z.infer; +export type ManagedMemoryStrategy = z.infer; // ============================================================================ // Truncation Configuration // ============================================================================ -export const HarnessTruncationStrategySchema = z.enum(['sliding_window', 'summarization']); +export const HarnessTruncationStrategySchema = z.enum(['sliding_window', 'summarization', 'none']); -export const SlidingWindowConfigSchema = z.object({ - slidingWindow: z.object({ - messagesCount: z.number().int().min(1).optional(), - }), -}); +// .strict() on the outer object so a payload carrying BOTH arms +// (`{ slidingWindow: {...}, summarization: {...} }`) fails the union — without it the union +// matches the first arm and silently drops the second arm's config. +export const SlidingWindowConfigSchema = z + .object({ + slidingWindow: z.object({ + messagesCount: z.number().int().min(1).optional(), + }), + }) + .strict(); -export const SummarizationConfigSchema = z.object({ - summarization: z.object({ - summaryRatio: z.number().min(0).max(1).optional(), - preserveRecentMessages: z.number().int().min(0).optional(), - summarizationSystemPrompt: z.string().optional(), - }), -}); +export const SummarizationConfigSchema = z + .object({ + summarization: z.object({ + summaryRatio: z.number().min(0).max(1).optional(), + preserveRecentMessages: z.number().int().min(0).optional(), + summarizationSystemPrompt: z.string().optional(), + }), + }) + .strict(); -export const HarnessTruncationConfigSchema = z.object({ - strategy: HarnessTruncationStrategySchema, - config: z.union([SlidingWindowConfigSchema, SummarizationConfigSchema]).optional(), -}); +export const HarnessTruncationConfigSchema = z + .object({ + strategy: HarnessTruncationStrategySchema, + config: z.union([SlidingWindowConfigSchema, SummarizationConfigSchema]).optional(), + }) + .superRefine((data, ctx) => { + // Bind the config arm to the chosen strategy: sliding_window must carry a slidingWindow + // config (or none), summarization a summarization config (or none), and none takes no config. + if (!data.config) return; + const configKey = 'slidingWindow' in data.config ? 'slidingWindow' : 'summarization'; + const expected: Record = { + sliding_window: 'slidingWindow', + summarization: 'summarization', + none: undefined, + }; + if (expected[data.strategy] === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Truncation strategy "${data.strategy}" does not take a config`, + path: ['config'], + }); + } else if (expected[data.strategy] !== configKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Truncation strategy "${data.strategy}" requires a "${expected[data.strategy]}" config, got "${configKey}"`, + path: ['config'], + }); + } + }); export type HarnessTruncationConfig = z.infer; +// ============================================================================ +// Skill Configuration +// ============================================================================ + +export const HarnessSkillGitAuthSchema = z.object({ + credentialName: z.string().min(1), + username: z.string().optional(), +}); + +export type HarnessSkillGitAuth = z.infer; + +export const HarnessSkillS3SourceSchema = z + .object({ + s3Uri: z + .string() + .min(5) + .regex(/^s3:\/\//, 'Must be an S3 URI starting with s3://'), + }) + .strict(); + +export type HarnessSkillS3Source = z.infer; + +export const HarnessSkillGitSourceSchema = z + .object({ + gitUrl: z + .string() + .min(8) + .regex(/^https:\/\//, 'Must be an HTTPS git URL'), + path: z.string().min(1).optional(), + auth: HarnessSkillGitAuthSchema.optional(), + }) + .strict(); + +export type HarnessSkillGitSource = z.infer; + +export const HarnessSkillPathSourceSchema = z + .object({ + path: z.string().min(1), + }) + .strict(); + +export type HarnessSkillPathSource = z.infer; + +export const HarnessSkillAwsSkillsSourceSchema = z + .object({ + awsSkills: z + .object({ + paths: z.array(z.string().min(1).max(4096)).optional(), + }) + .strict(), + }) + .strict(); + +export type HarnessSkillAwsSkillsSource = z.infer; + +export const HarnessSkillSchema = z.union([ + z + .string() + .min(1) + .transform(path => ({ path })), + HarnessSkillS3SourceSchema, + HarnessSkillGitSourceSchema, + HarnessSkillPathSourceSchema, + HarnessSkillAwsSkillsSourceSchema, +]); + +export type HarnessSkillInput = z.input; +export type HarnessSkill = z.output; + // ============================================================================ // Allowed Tools // ============================================================================ @@ -284,6 +528,18 @@ export const AllowedToolSchema = z // eslint-disable-next-line security/detect-unsafe-regex -- safe: input is bounded to 64 chars by .max(64) .regex(/^(\*|@?[^/]+(\/[^/]+)?)$/, 'Must be "*" or a tool name pattern (max 64 chars)'); +/** + * Detects the legacy file-path-shaped `systemPrompt` (the pre-migration `./prompt.md` style that + * the old L3 read from disk). A real system prompt is instructional prose containing whitespace; + * the legacy shape was always a single bare token that is a relative path or ends in .md/.txt. + * Used to fail fast on upgrade rather than silently shipping the path string as the prompt text. + */ +export function looksLikeLegacyPromptPath(value: string): boolean { + const v = value.trim(); + if (!/^\S+$/.test(v)) return false; // prose contains whitespace — never a path + return /^\.\.?\//.test(v) || /\.(md|txt)$/i.test(v); +} + // ============================================================================ // HarnessSpec — per-harness config file schema (harness.json) // ============================================================================ @@ -292,7 +548,21 @@ export const HarnessSpecSchema = z .object({ name: HarnessNameSchema, model: HarnessModelSchema, - systemPrompt: z.string().optional(), + // Always literal text. CFN HarnessSystemContentBlock.Text is minLength:1, so a blank or + // whitespace-only prompt would deploy to CREATE_FAILED — reject it client-side instead. + // (File-backed prompts are supplied via system-prompt.md auto-discovery, not this field.) + systemPrompt: z + .string() + .refine(val => val.trim().length > 0, { message: 'systemPrompt must not be empty or whitespace-only' }) + // Migration fail-fast: the previous L3 treated a `./prompt.md`-style value as a file path and + // loaded its contents; this field is now ALWAYS literal text, so such a value would silently + // ship as the prompt itself. Reject the legacy file-path shape so the divergence fails loudly + // at parse/validate time (before synth) instead of in production. + .refine(val => !looksLikeLegacyPromptPath(val), { + message: + 'systemPrompt looks like a file path. It is now always literal text — put file-backed prompts in a `system-prompt.md` in the harness directory (auto-discovered), or inline the prompt text here.', + }) + .optional(), tools: z .array(HarnessToolSchema) .default([]) @@ -302,27 +572,35 @@ export const HarnessSpecSchema = z name => `Duplicate tool name: ${name}` ) ), - skills: z.array(z.string().min(1)).default([]), + skills: z.array(HarnessSkillSchema).default([]), allowedTools: z.array(AllowedToolSchema).optional(), memory: HarnessMemoryRefSchema.optional(), maxIterations: z.number().int().min(1).optional(), maxTokens: z.number().int().min(1).optional(), timeoutSeconds: z.number().int().min(1).optional(), truncation: HarnessTruncationConfigSchema.optional(), - containerUri: z.string().min(1).optional(), + containerUri: z + .string() + .min(1) + .max(MAX_CONTAINER_URI_LENGTH) + .regex(CONTAINER_URI_PATTERN, 'containerUri must be an ECR image URI (12-digit private ECR or public.ecr.aws)') + .optional(), dockerfile: z.string().min(1).optional(), executionRoleArn: z.string().optional(), networkMode: NetworkModeSchema.optional(), networkConfig: NetworkConfigSchema.optional(), lifecycleConfig: LifecycleConfigurationSchema.optional(), - sessionStoragePath: z - .string() - .min(1) - .refine(val => val.startsWith('/mnt/'), { message: 'sessionStoragePath must be an absolute path under /mnt/' }) - .optional(), + sessionStoragePath: SessionStorageSchema.shape.mountPath.optional(), efsAccessPoints: z.array(EfsAccessPointConfigSchema).max(2).optional(), s3AccessPoints: z.array(S3FilesAccessPointConfigSchema).max(2).optional(), - environmentVariables: z.record(z.string(), z.string()).optional(), + environmentVariables: z + // Key bound (Smithy EnvironmentVariableKey: length 1–100, no character pattern) — an empty or + // >100-char key passes a bare z.string() here but fails at CFN CREATE. + .record(z.string().min(1).max(MAX_ENV_VAR_KEY_LENGTH), z.string().max(MAX_ENV_VAR_VALUE_LENGTH)) + .refine(rec => Object.keys(rec).length <= MAX_ENV_VARS, { + message: `A maximum of ${MAX_ENV_VARS} environment variables is allowed`, + }) + .optional(), /** Authorizer type for inbound requests. Defaults to AWS_IAM. */ authorizerType: RuntimeAuthorizerTypeSchema.optional(), /** Authorizer configuration. Required when authorizerType is CUSTOM_JWT. */ diff --git a/src/schema/schemas/primitives/http-gateway.ts b/src/schema/schemas/primitives/http-gateway.ts deleted file mode 100644 index 4773b32e2..000000000 --- a/src/schema/schemas/primitives/http-gateway.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { z } from 'zod'; - -// ============================================================================ -// HTTP Gateway Types -// ============================================================================ - -export const HttpGatewayNameSchema = z - .string() - .min(1, 'Name is required') - .regex( - /^[a-zA-Z][a-zA-Z0-9-]*$/, - 'Gateway name must start with a letter and contain only alphanumeric characters or hyphens (combined with project name must fit 48-char AWS limit)' - ); - -export const HttpGatewayTargetSchema = z.object({ - /** Gateway target name (referenced by AB test variants) */ - name: z.string().min(1).max(100), - /** Reference to a runtime name from spec.runtimes */ - runtimeRef: z.string().min(1), - /** Endpoint qualifier on the runtime (e.g., 'prod', 'staging'). Defaults to 'DEFAULT'. */ - qualifier: z.string().min(1).default('DEFAULT'), -}); - -export type HttpGatewayTarget = z.infer; - -export const HttpGatewaySchema = z - .object({ - /** Unique name for the HTTP gateway */ - name: HttpGatewayNameSchema, - /** Optional description */ - description: z.string().min(1).max(200).optional(), - /** Reference to a runtime name from spec.runtimes. One target is created per gateway pointing to this runtime. */ - runtimeRef: z.string().min(1), - /** IAM role ARN for gateway execution. Auto-created if omitted. */ - roleArn: z.string().min(1).optional(), - /** Additional targets for the gateway (for target-based AB testing). */ - targets: z.array(HttpGatewayTargetSchema).optional(), - }) - .strict(); - -export type HttpGateway = z.infer; diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index f9ae0e856..d988ee49f 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -59,11 +59,13 @@ export { RatingScaleSchema, } from './evaluator'; -export type { OnlineEvalConfig } from './online-eval-config'; -export { OnlineEvalConfigSchema, OnlineEvalConfigNameSchema } from './online-eval-config'; +export type { OnlineEvalConfig, ClusteringConfig } from './online-eval-config'; +export { OnlineEvalConfigSchema, OnlineEvalConfigNameSchema, ClusteringConfigSchema } from './online-eval-config'; -export type { Policy, PolicyEngine, ValidationMode } from './policy'; +export type { AuthorizationPhase, EnforcementMode, Policy, PolicyEngine, ValidationMode } from './policy'; export { + AuthorizationPhaseSchema, + EnforcementModeSchema, PolicyEngineNameSchema, PolicyEngineSchema, PolicyNameSchema, @@ -82,6 +84,7 @@ export type { HarnessTool, HarnessToolType, HarnessTruncationConfig, + ManagedMemoryStrategy, OpenAiApiFormat, } from './harness'; export { @@ -102,11 +105,9 @@ export { HarnessToolTypeSchema, HarnessTruncationConfigSchema, HarnessTruncationStrategySchema, + ManagedMemoryStrategySchema, } from './harness'; -export type { HttpGateway } from './http-gateway'; -export { HttpGatewayNameSchema, HttpGatewaySchema } from './http-gateway'; - export type { PaymentManager, PaymentConnector, PaymentProvider, PaymentAuthorizerType } from './payment'; export { DEFAULT_AUTO_PAYMENT, diff --git a/src/schema/schemas/primitives/knowledge-base.ts b/src/schema/schemas/primitives/knowledge-base.ts new file mode 100644 index 000000000..5940d620a --- /dev/null +++ b/src/schema/schemas/primitives/knowledge-base.ts @@ -0,0 +1,122 @@ +import { uniqueBy } from '../zod-util'; +import { z } from 'zod'; + +/** + * Knowledge Base name validation. + * 1-48 chars, starts with a letter, alphanumeric + dash + underscore. + * Mirrors the naming convention used by Memory/Evaluator/Dataset primitives; + * stricter than the Bedrock CreateKnowledgeBase API allows (which permits up + * to 100 chars), but consistent with the rest of the agentcore-cli schemas. + */ +export const KnowledgeBaseNameSchema = z + .string() + .min(1, 'Name is required') + .max(48) + .regex( + /^[a-zA-Z][a-zA-Z0-9_-]{0,47}$/, + 'Must begin with a letter and contain only alphanumeric characters, dashes, and underscores (max 48 chars)' + ); + +/** + * S3 data source for FMKB. Wave 1 supports S3 only. + * + * `uri` must be an `s3://bucket[/prefix]` URI. Non-S3 connectors (Confluence, + * SharePoint, OneDrive, Google Drive, Web Crawler) are deferred to a later wave. + * + * Bucket validation enforces the AWS S3 bucket naming rules at parse time: + * 3-63 chars, lowercase + digits + dot + hyphen, must start and end with + * letter/digit, no consecutive dots, and none of the AWS-reserved prefixes + * (`xn--`, `sthree-`) or suffixes (`-s3alias`). AWS will ultimately reject + * non-conformant buckets server-side; failing fast at config-load time + * gives a better error surface. + */ +const S3_BUCKET_NAME = /^(?!xn--)(?!sthree-)[a-z0-9](?!.*\.\.)[a-z0-9.-]{1,61}[a-z0-9](? { + const m = /^s3:\/\/([^/]+)(?:\/.*)?$/.exec(s); + return !!m && S3_BUCKET_NAME.test(m[1]!); + }, + { message: 'Must be a valid s3:// URI with an AWS-compliant bucket name' } + ), + }) + .strict(); + +export type S3DataSource = z.infer; + +/** + * Wire-verbatim connector type values for non-S3 FMKB data sources. These are + * the exact `connectorParameters.type` literals the Bedrock managed-connector + * API uses (confirmed against the FMKB console module's read and write paths). + * Note `WEB` (not `WEBCRAWLER`) and the single-word `GOOGLEDRIVE`. + */ +export const ConnectorDataSourceTypeSchema = z.enum(['WEB', 'CONFLUENCE', 'SHAREPOINT', 'ONEDRIVE', 'GOOGLEDRIVE']); +export type ConnectorDataSourceType = z.infer; + +/** + * Non-S3 data source. The connector-specific structure lives in a JSON file + * (`connectorConfigFile`, a project-relative path) and is passed through to + * the DataSource's connectorParameters verbatim at deploy time. This honors + * the DevEx "JSON file passthrough" decision: new connector params don't + * require CLI/schema changes. + */ +export const ConnectorFileDataSourceSchema = z + .object({ + type: ConnectorDataSourceTypeSchema, + connectorConfigFile: z.string().min(1, 'connectorConfigFile path is required'), + }) + .strict(); + +export type ConnectorFileDataSource = z.infer; + +/** + * Knowledge Base data source: S3 (inline `uri`) or a non-S3 connector + * (file-path reference). Discriminated union on `type`. + */ +export const DataSourceSchema = z.discriminatedUnion('type', [S3DataSourceSchema, ConnectorFileDataSourceSchema]); +export type DataSource = z.infer; + +/** + * Type literal for KnowledgeBase entries in `agentcore.json`. + * Mirrors how Memory/Evaluator/etc. tag themselves for forward-compat. + */ +export const KnowledgeBaseTypeSchema = z.literal('AgentCoreKnowledgeBase'); +export type KnowledgeBaseType = z.infer; + +/** + * Knowledge Base entry. The CLI creates and owns the KB, its data sources, + * its IAM role, and (when `gateway` is set in Wave 2) its connector gateway + * target. + * + * To wire an EXTERNAL KB (one this project does not own) as a gateway target, + * skip this schema and use a connector gateway target with the external KB's + * literal 10-char ID set on `knowledgeBaseId`. See `agentcore add gateway- + * target --type connector --connector bedrock-knowledge-bases`. + * + * `gateway` is optional in Wave 1 — when set, it's stored but not yet wired + * to a gateway target. Wave 2 lights up the connector gateway target. + */ +export const KnowledgeBaseSchema = z + .object({ + type: KnowledgeBaseTypeSchema.default('AgentCoreKnowledgeBase'), + name: KnowledgeBaseNameSchema, + description: z.string().max(2048).optional(), + dataSources: z + .array(DataSourceSchema) + .min(1, 'At least one data source is required') + .superRefine( + uniqueBy( + ds => (ds.type === 'S3' ? ds.uri : ds.connectorConfigFile), + key => `Duplicate data source: ${key}` + ) + ), + gateway: z.string().min(1).optional(), + }) + .strict(); + +export type KnowledgeBase = z.infer; diff --git a/src/schema/schemas/primitives/online-eval-config.ts b/src/schema/schemas/primitives/online-eval-config.ts index 5b6f13cb6..d95bde8d9 100644 --- a/src/schema/schemas/primitives/online-eval-config.ts +++ b/src/schema/schemas/primitives/online-eval-config.ts @@ -14,21 +14,65 @@ export const OnlineEvalConfigNameSchema = z 'Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)' ); -export const OnlineEvalConfigSchema = z.object({ - name: OnlineEvalConfigNameSchema, - /** Agent name to monitor (must match a project agent) */ - agent: z.string().min(1, 'Agent name is required'), - /** Optional runtime endpoint name to scope monitoring to a specific endpoint */ - endpoint: z.string().min(1).optional(), - /** Evaluator names (custom), Builtin.* IDs, or evaluator ARNs */ - evaluators: z.array(z.string().min(1)).min(1, 'At least one evaluator is required'), - /** Sampling rate as a percentage (0.01 to 100) */ - samplingRate: z.number().min(0.01).max(100), - /** Optional description for the online eval config */ - description: z.string().max(200).optional(), - /** Whether to enable execution on create (default: true) */ - enableOnCreate: z.boolean().optional(), - tags: TagsSchema.optional(), +export const ClusteringConfigSchema = z.object({ + frequencies: z + .array(z.enum(['DAILY', 'WEEKLY', 'MONTHLY'])) + .min(1) + .max(3), }); +export type ClusteringConfig = z.infer; + +export const OnlineEvalConfigSchema = z + .object({ + name: OnlineEvalConfigNameSchema, + /** Agent name to monitor (must match a project agent). Required when using managed AgentCore agents. */ + agent: z.string().min(1, 'Agent name is required').optional(), + /** Optional runtime endpoint name to scope monitoring to a specific endpoint */ + endpoint: z.string().min(1).optional(), + /** CloudWatch log group names for custom/3rd-party agents (1-5 entries) */ + logGroupNames: z.array(z.string().min(1)).min(1).max(5).optional(), + /** Service names to filter traces for custom/3rd-party agents */ + serviceNames: z.array(z.string().min(1)).min(1).optional(), + /** Evaluator names (custom), Builtin.* IDs, or evaluator ARNs */ + evaluators: z.array(z.string().min(1)).optional(), + /** Insight IDs for continuous analysis */ + insights: z.array(z.string().min(1)).optional(), + /** Clustering configuration (requires insights) */ + clusteringConfig: ClusteringConfigSchema.optional(), + /** Sampling rate as a percentage (0.01 to 100) */ + samplingRate: z.number().min(0.01).max(100), + /** Optional description for the online eval config */ + description: z.string().max(200).optional(), + /** Whether to enable execution on create (default: true) */ + enableOnCreate: z.boolean().optional(), + tags: TagsSchema.optional(), + }) + .refine(data => data.agent ?? data.logGroupNames, { + message: 'Either "agent" or "logGroupNames" must be provided', + }) + .refine(data => !(data.agent && data.logGroupNames), { + message: '"agent" and "logGroupNames" are mutually exclusive', + }) + .refine(data => !data.endpoint || data.agent, { + message: '"endpoint" requires "agent"', + }) + .refine(data => !data.serviceNames || data.logGroupNames, { + message: '"serviceNames" requires "logGroupNames"', + }) + .refine( + data => { + const hasEvaluators = data.evaluators != null && data.evaluators.length > 0; + const hasInsights = data.insights != null && data.insights.length > 0; + return hasEvaluators || hasInsights; + }, + { message: 'At least one of evaluators or insights must be provided' } + ) + .refine(data => !(data.evaluators && data.evaluators.length > 0 && data.insights && data.insights.length > 0), { + message: 'Cannot have both evaluators and insights (preview constraint)', + }) + .refine(data => !data.clusteringConfig || (data.insights && data.insights.length > 0), { + message: 'clusteringConfig requires insights to be set', + }); + export type OnlineEvalConfig = z.infer; diff --git a/src/schema/schemas/primitives/policy.ts b/src/schema/schemas/primitives/policy.ts index e80385f94..4c47d86a1 100644 --- a/src/schema/schemas/primitives/policy.ts +++ b/src/schema/schemas/primitives/policy.ts @@ -47,6 +47,12 @@ export const PolicyNameSchema = z export const ValidationModeSchema = z.enum(['FAIL_ON_ANY_FINDINGS', 'IGNORE_ALL_FINDINGS']); export type ValidationMode = z.infer; +export const AuthorizationPhaseSchema = z.enum(['INITIATE', 'RETURN_OUTPUT']).default('INITIATE'); +export type AuthorizationPhase = z.infer; + +export const EnforcementModeSchema = z.enum(['ACTIVE', 'LOG_ONLY']).default('ACTIVE'); +export type EnforcementMode = z.infer; + // ============================================================================ // Policy Schema // ============================================================================ @@ -57,6 +63,8 @@ export const PolicySchema = z.object({ statement: z.string().min(1, 'Cedar policy statement is required'), sourceFile: z.string().optional(), validationMode: ValidationModeSchema.default('FAIL_ON_ANY_FINDINGS'), + enforcementMode: EnforcementModeSchema.default('ACTIVE'), + authorizationPhase: AuthorizationPhaseSchema.optional(), }); export type Policy = z.infer; From 26f0ee65535b79f00edce99eba1c739ac6f49292 Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 29 Jun 2026 19:01:35 -0400 Subject: [PATCH 27/27] fix(tui): validate empty input in SecretInput before triggering cancel Previously, pressing Enter with an empty value in SecretInput would call onCancel (which navigates back) even when customValidation rejected empty values. Now validation runs first, showing the error message instead of silently going back. Fixes aws/agentcore-cli#1618 --- src/cli/tui/components/SecretInput.tsx | 9 +++++++-- .../components/__tests__/SecretInput.test.tsx | 20 +++++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/cli/tui/components/SecretInput.tsx b/src/cli/tui/components/SecretInput.tsx index 7794b4aa6..ff6049996 100644 --- a/src/cli/tui/components/SecretInput.tsx +++ b/src/cli/tui/components/SecretInput.tsx @@ -38,8 +38,6 @@ export interface SecretInputProps { } function validateValue(value: string, schema?: ZodString, customValidation?: CustomValidation): string | undefined { - if (!value) return undefined; - if (customValidation) { const result = customValidation(value); if (result !== true) { @@ -47,6 +45,8 @@ function validateValue(value: string, schema?: ZodString, customValidation?: Cus } } + if (!value) return undefined; + if (schema) { const parseResult = schema.safeParse(value); if (!parseResult.success) { @@ -89,6 +89,11 @@ export function SecretInput({ onSubmit: val => { const trimmed = val.trim(); if (!trimmed) { + const validationError = validateValue(trimmed, schema, customValidation); + if (validationError) { + setShowError(true); + return; + } if (onSkip) { onSkip(); } else { diff --git a/src/cli/tui/components/__tests__/SecretInput.test.tsx b/src/cli/tui/components/__tests__/SecretInput.test.tsx index cf00b0378..1404b9f8f 100644 --- a/src/cli/tui/components/__tests__/SecretInput.test.tsx +++ b/src/cli/tui/components/__tests__/SecretInput.test.tsx @@ -1,7 +1,6 @@ import { ApiKeySecretInput, SecretInput } from '../SecretInput.js'; import { render } from 'ink-testing-library'; import React from 'react'; -import stripAnsi from 'strip-ansi'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; @@ -35,7 +34,7 @@ describe('SecretInput', () => { ); - expect(stripAnsi(lastFrame()!)).toContain('sk-...'); + expect(lastFrame()).toContain('sk-...'); }); it('masks input with default * character', async () => { @@ -150,6 +149,23 @@ describe('SecretInput', () => { expect(onCancel).toHaveBeenCalledTimes(1); }); + it('shows validation error instead of calling onCancel when customValidation rejects empty value', async () => { + const onCancel = vi.fn(); + const onSubmit = vi.fn(); + const customValidation = (val: string) => val.trim().length > 0 || 'API key is required'; + const { lastFrame, stdin } = render( + + ); + + await delay(); + stdin.write(ENTER); + await delay(); + + expect(onCancel).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('API key is required'); + }); + it('shows skip hint when onSkip is provided', () => { const { lastFrame } = render();